diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f6ac553..0dd5e83 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -208,6 +208,8 @@ jobs: bundler-cache: true - run: ruby -c fastlane/Fastfile - run: bundle exec fastlane lanes + - name: Test the Play upload configuration without publishing + run: bundle exec ruby test/tool/fastlane_test.rb - name: Check tools did not mutate the checkout run: | git diff --exit-code diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 26087ef..3b3e1de 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -124,13 +124,12 @@ jobs: echo "version=$version" >> "$GITHUB_OUTPUT" echo "Building $version ($number)." # Before the slow build, so a changelog Play would refuse fails in - # seconds. Into the runner's temporary directory, not the checkout. - - name: Write the Play release notes + # seconds. Package the notes and artwork into the runner's temporary + # directory, leaving the checkout and generated screenshots untouched. + - name: Prepare the Play listing and release notes run: | - notes="$RUNNER_TEMP/play-release-notes/en-US/changelogs" - mkdir -p "$notes" - dart run tool/release_notes.dart > "$notes/default.txt" - cat "$notes/default.txt" + dart run tool/play_metadata.dart --output="$RUNNER_TEMP/play-metadata" + cat "$RUNNER_TEMP/play-metadata/en-US/changelogs/default.txt" - run: dart run build_runner build - name: Build the web bundle env: @@ -169,6 +168,12 @@ jobs: build/debug-info/android if-no-files-found: error retention-days: 30 + - uses: actions/upload-artifact@v7 + with: + name: opensplit-play-metadata-${{ github.sha }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/play-metadata + if-no-files-found: error + retention-days: 30 # Mint short-lived credentials only AFTER builds and artifact uploads. - name: Recheck current main before deployment @@ -246,9 +251,10 @@ jobs: # update; 4 and up interrupt testers with a blocking flow, which is # for a build the server can no longer talk to an older client from. PLAY_UPDATE_PRIORITY: ${{ inputs.update_priority || vars.PLAY_UPDATE_PRIORITY || '0' }} - # Written above, from CHANGELOG.md. en-US is the store listing's - # language; Play refuses notes in one the listing does not have. - PLAY_RELEASE_NOTES_DIR: ${{ runner.temp }}/play-release-notes + # Written above, from CHANGELOG.md and the committed artwork. + # en-US is the store listing's language; images affect the listing + # across tracks, even though the bundle goes to closed testing. + PLAY_METADATA_DIR: ${{ runner.temp }}/play-metadata run: bundle exec fastlane android closed - name: Remove temporary upload key if: always() diff --git a/.gitignore b/.gitignore index 2934031..cd7844e 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,10 @@ migrate_working_dir/ /build/ /coverage/ +# Launcher generator intermediates; tool/brand_icons.dart publishes site/icons. +/web/icons/ +/web/favicon.png + # Browser-test server serves test/, so copy the shipped SQLite assets here. /test/browser/sqlite3.wasm /test/browser/drift_worker.js diff --git a/CHANGELOG.md b/CHANGELOG.md index f62b64d..ca575c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,22 @@ What changed in each version of the app, newest first. The format is [Semantic Versioning](https://semver.org/), and the file is kept by [cider](https://pub.dev/packages/cider): see "Versions" in docs/runbook.md. +## [3.1.0] - 2026-10-11 +### Added +- Amount and percentage splits, and multiple payers, start evenly and share the remainder as you edit. Saved splits keep their values. + +### Changed +- A refreshed Material design with indigo colours, Google Sans Flex, consistent fields and clearer expense entry. +- Groups share their cover art and name as they open, with transitions that respect reduced motion. +- Recorded payments open in a dedicated editor showing who paid whom, with an optional note. +- Custom split values have a small pin and an even-split reset. +- The launch screen, landing page, emails and Play artwork match the new design. + +### Fixed +- Android device colours refresh when you return to the app or switch light and dark mode. Settings can check colour availability again. +- A saved payment keeps its sender, recipient and payment type while editing. +- Expense previews and saved splits assign leftover cents to the same people. + ## [3.0.0] - 2026-10-10 ### Added - On the web, notifications now arrive when OpenSplit isn't open, and tapping one opens the group. @@ -74,6 +90,7 @@ What changed in each version of the app, newest first. The format is ### Added - The first build, to Play internal testing. +[3.1.0]: https://github.com/eigeninteractive/opensplit/compare/v3.0.0...v3.1.0 [3.0.0]: https://github.com/eigeninteractive/opensplit/compare/v2.0.0...v3.0.0 [2.0.0]: https://github.com/eigeninteractive/opensplit/compare/v1.0.0...v2.0.0 [1.0.0]: https://github.com/eigeninteractive/opensplit/releases/tag/v1.0.0 diff --git a/README.md b/README.md index 6d574a3..b65f7b8 100644 --- a/README.md +++ b/README.md @@ -892,7 +892,7 @@ leaving it to this file: *Settings → About* links to the source, and `REPOSITORY_URL` is a build-time define so that a fork's copy points at the fork. -The typeface is not ours and is not under that licence. Instrument Sans is +The typeface is not ours and is not under that licence. Google Sans Flex is bundled under the SIL Open Font License 1.1, with the notice and the licence text in [assets/google_fonts/LICENSE](assets/google_fonts/LICENSE) — which the app also shows, under *About → Open-source licences*. diff --git a/android/app/src/main/res/drawable-hdpi/android12splash.png b/android/app/src/main/res/drawable-hdpi/android12splash.png index 53a7c09..9d302f6 100644 Binary files a/android/app/src/main/res/drawable-hdpi/android12splash.png and b/android/app/src/main/res/drawable-hdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png index ae59b4d..ec65027 100644 Binary files a/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-hdpi/ic_launcher_monochrome.png b/android/app/src/main/res/drawable-hdpi/ic_launcher_monochrome.png index 32c9c86..3d03fef 100644 Binary files a/android/app/src/main/res/drawable-hdpi/ic_launcher_monochrome.png and b/android/app/src/main/res/drawable-hdpi/ic_launcher_monochrome.png differ diff --git a/android/app/src/main/res/drawable-hdpi/ic_stat_opensplit.png b/android/app/src/main/res/drawable-hdpi/ic_stat_opensplit.png index 00cd25e..a390712 100644 Binary files a/android/app/src/main/res/drawable-hdpi/ic_stat_opensplit.png and b/android/app/src/main/res/drawable-hdpi/ic_stat_opensplit.png differ diff --git a/android/app/src/main/res/drawable-hdpi/splash.png b/android/app/src/main/res/drawable-hdpi/splash.png index 53a7c09..9d302f6 100644 Binary files a/android/app/src/main/res/drawable-hdpi/splash.png and b/android/app/src/main/res/drawable-hdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-mdpi/android12splash.png b/android/app/src/main/res/drawable-mdpi/android12splash.png index 451a08d..d25cb08 100644 Binary files a/android/app/src/main/res/drawable-mdpi/android12splash.png and b/android/app/src/main/res/drawable-mdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png index c56b35f..22d3fa8 100644 Binary files a/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_launcher_monochrome.png b/android/app/src/main/res/drawable-mdpi/ic_launcher_monochrome.png index 4d1eaaa..fe66f9e 100644 Binary files a/android/app/src/main/res/drawable-mdpi/ic_launcher_monochrome.png and b/android/app/src/main/res/drawable-mdpi/ic_launcher_monochrome.png differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_stat_opensplit.png b/android/app/src/main/res/drawable-mdpi/ic_stat_opensplit.png index cc72b85..8d962ba 100644 Binary files a/android/app/src/main/res/drawable-mdpi/ic_stat_opensplit.png and b/android/app/src/main/res/drawable-mdpi/ic_stat_opensplit.png differ diff --git a/android/app/src/main/res/drawable-mdpi/splash.png b/android/app/src/main/res/drawable-mdpi/splash.png index 451a08d..d25cb08 100644 Binary files a/android/app/src/main/res/drawable-mdpi/splash.png and b/android/app/src/main/res/drawable-mdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-night-hdpi/android12splash.png b/android/app/src/main/res/drawable-night-hdpi/android12splash.png index 849b399..b63b5d1 100644 Binary files a/android/app/src/main/res/drawable-night-hdpi/android12splash.png and b/android/app/src/main/res/drawable-night-hdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-night-hdpi/splash.png b/android/app/src/main/res/drawable-night-hdpi/splash.png index 849b399..b63b5d1 100644 Binary files a/android/app/src/main/res/drawable-night-hdpi/splash.png and b/android/app/src/main/res/drawable-night-hdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-night-mdpi/android12splash.png b/android/app/src/main/res/drawable-night-mdpi/android12splash.png index c02173c..305cc34 100644 Binary files a/android/app/src/main/res/drawable-night-mdpi/android12splash.png and b/android/app/src/main/res/drawable-night-mdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-night-mdpi/splash.png b/android/app/src/main/res/drawable-night-mdpi/splash.png index c02173c..305cc34 100644 Binary files a/android/app/src/main/res/drawable-night-mdpi/splash.png and b/android/app/src/main/res/drawable-night-mdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-night-v21/background.png b/android/app/src/main/res/drawable-night-v21/background.png index 73d7ed0..3d5e7b2 100644 Binary files a/android/app/src/main/res/drawable-night-v21/background.png and b/android/app/src/main/res/drawable-night-v21/background.png differ diff --git a/android/app/src/main/res/drawable-night-xhdpi/android12splash.png b/android/app/src/main/res/drawable-night-xhdpi/android12splash.png index 7e7f53c..4f82301 100644 Binary files a/android/app/src/main/res/drawable-night-xhdpi/android12splash.png and b/android/app/src/main/res/drawable-night-xhdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-night-xhdpi/splash.png b/android/app/src/main/res/drawable-night-xhdpi/splash.png index 7e7f53c..4f82301 100644 Binary files a/android/app/src/main/res/drawable-night-xhdpi/splash.png and b/android/app/src/main/res/drawable-night-xhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png b/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png index 73e2009..592e333 100644 Binary files a/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png and b/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-night-xxhdpi/splash.png b/android/app/src/main/res/drawable-night-xxhdpi/splash.png index 73e2009..592e333 100644 Binary files a/android/app/src/main/res/drawable-night-xxhdpi/splash.png and b/android/app/src/main/res/drawable-night-xxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png b/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png index 1ad4a47..48f2638 100644 Binary files a/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png and b/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-night-xxxhdpi/splash.png b/android/app/src/main/res/drawable-night-xxxhdpi/splash.png index 1ad4a47..48f2638 100644 Binary files a/android/app/src/main/res/drawable-night-xxxhdpi/splash.png and b/android/app/src/main/res/drawable-night-xxxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-night/background.png b/android/app/src/main/res/drawable-night/background.png index 73d7ed0..3d5e7b2 100644 Binary files a/android/app/src/main/res/drawable-night/background.png and b/android/app/src/main/res/drawable-night/background.png differ diff --git a/android/app/src/main/res/drawable-v21/background.png b/android/app/src/main/res/drawable-v21/background.png index 5c84983..c5028b7 100644 Binary files a/android/app/src/main/res/drawable-v21/background.png and b/android/app/src/main/res/drawable-v21/background.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/android12splash.png b/android/app/src/main/res/drawable-xhdpi/android12splash.png index 442027f..7722688 100644 Binary files a/android/app/src/main/res/drawable-xhdpi/android12splash.png and b/android/app/src/main/res/drawable-xhdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png index 64b3505..e975dd8 100644 Binary files a/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/ic_launcher_monochrome.png b/android/app/src/main/res/drawable-xhdpi/ic_launcher_monochrome.png index c1a1902..0dd70e9 100644 Binary files a/android/app/src/main/res/drawable-xhdpi/ic_launcher_monochrome.png and b/android/app/src/main/res/drawable-xhdpi/ic_launcher_monochrome.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/ic_stat_opensplit.png b/android/app/src/main/res/drawable-xhdpi/ic_stat_opensplit.png index b8d9190..4f7b18a 100644 Binary files a/android/app/src/main/res/drawable-xhdpi/ic_stat_opensplit.png and b/android/app/src/main/res/drawable-xhdpi/ic_stat_opensplit.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/splash.png b/android/app/src/main/res/drawable-xhdpi/splash.png index 442027f..7722688 100644 Binary files a/android/app/src/main/res/drawable-xhdpi/splash.png and b/android/app/src/main/res/drawable-xhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/android12splash.png b/android/app/src/main/res/drawable-xxhdpi/android12splash.png index d0b618b..60d83d6 100644 Binary files a/android/app/src/main/res/drawable-xxhdpi/android12splash.png and b/android/app/src/main/res/drawable-xxhdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png index dc8ec37..0b37e5d 100644 Binary files a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_monochrome.png b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_monochrome.png index 29dae5f..9ed0210 100644 Binary files a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_monochrome.png and b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_monochrome.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_stat_opensplit.png b/android/app/src/main/res/drawable-xxhdpi/ic_stat_opensplit.png index a9d580a..3173015 100644 Binary files a/android/app/src/main/res/drawable-xxhdpi/ic_stat_opensplit.png and b/android/app/src/main/res/drawable-xxhdpi/ic_stat_opensplit.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/splash.png b/android/app/src/main/res/drawable-xxhdpi/splash.png index d0b618b..60d83d6 100644 Binary files a/android/app/src/main/res/drawable-xxhdpi/splash.png and b/android/app/src/main/res/drawable-xxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/android12splash.png b/android/app/src/main/res/drawable-xxxhdpi/android12splash.png index ce18e3f..f8ef9ea 100644 Binary files a/android/app/src/main/res/drawable-xxxhdpi/android12splash.png and b/android/app/src/main/res/drawable-xxxhdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png index d55026b..a1cb846 100644 Binary files a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_monochrome.png b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_monochrome.png index 325f3f6..d2f714a 100644 Binary files a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_monochrome.png and b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_monochrome.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_stat_opensplit.png b/android/app/src/main/res/drawable-xxxhdpi/ic_stat_opensplit.png index 6d02a40..ef5cd62 100644 Binary files a/android/app/src/main/res/drawable-xxxhdpi/ic_stat_opensplit.png and b/android/app/src/main/res/drawable-xxxhdpi/ic_stat_opensplit.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/splash.png b/android/app/src/main/res/drawable-xxxhdpi/splash.png index ce18e3f..f8ef9ea 100644 Binary files a/android/app/src/main/res/drawable-xxxhdpi/splash.png and b/android/app/src/main/res/drawable-xxxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable/background.png b/android/app/src/main/res/drawable/background.png index 5c84983..c5028b7 100644 Binary files a/android/app/src/main/res/drawable/background.png and b/android/app/src/main/res/drawable/background.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/launcher_icon.png b/android/app/src/main/res/mipmap-hdpi/launcher_icon.png index d610e18..ee9961b 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/launcher_icon.png and b/android/app/src/main/res/mipmap-hdpi/launcher_icon.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/launcher_icon.png b/android/app/src/main/res/mipmap-mdpi/launcher_icon.png index 65f8de4..9d6f56b 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/launcher_icon.png and b/android/app/src/main/res/mipmap-mdpi/launcher_icon.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png b/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png index 188402a..11231df 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png and b/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png b/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png index 2cb195b..54b8e9c 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png and b/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png b/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png index 5066fca..56d9b6c 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png and b/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png differ diff --git a/android/app/src/main/res/values-night-v31/styles.xml b/android/app/src/main/res/values-night-v31/styles.xml index 90bcb41..e200fb3 100644 --- a/android/app/src/main/res/values-night-v31/styles.xml +++ b/android/app/src/main/res/values-night-v31/styles.xml @@ -14,7 +14,7 @@ false false shortEdges - #131318 + #121318 @drawable/android12splash - #fcf8ff - #E3DFFF + #fbf8ff + #DEE0FF \ No newline at end of file diff --git a/assets/brand/lockup-horizontal.svg b/assets/brand/lockup-horizontal.svg index 4d8b742..f8e8c71 100644 --- a/assets/brand/lockup-horizontal.svg +++ b/assets/brand/lockup-horizontal.svg @@ -5,8 +5,8 @@ - - + OpenSplit diff --git a/assets/brand/lockup-vertical.svg b/assets/brand/lockup-vertical.svg index ec6c7a8..74729a8 100644 --- a/assets/brand/lockup-vertical.svg +++ b/assets/brand/lockup-vertical.svg @@ -5,8 +5,8 @@ - - + OpenSplit diff --git a/assets/brand/mark-1024-dark.png b/assets/brand/mark-1024-dark.png index fc0bf8b..fb53ad0 100644 Binary files a/assets/brand/mark-1024-dark.png and b/assets/brand/mark-1024-dark.png differ diff --git a/assets/brand/mark-1024-light.png b/assets/brand/mark-1024-light.png index a283182..ade7aed 100644 Binary files a/assets/brand/mark-1024-light.png and b/assets/brand/mark-1024-light.png differ diff --git a/assets/brand/mark-on-dark.svg b/assets/brand/mark-on-dark.svg index 5814788..2d88347 100644 --- a/assets/brand/mark-on-dark.svg +++ b/assets/brand/mark-on-dark.svg @@ -5,5 +5,5 @@ - + diff --git a/assets/brand/mark-on-primary-container.svg b/assets/brand/mark-on-primary-container.svg index 513c54d..7ec45ea 100644 --- a/assets/brand/mark-on-primary-container.svg +++ b/assets/brand/mark-on-primary-container.svg @@ -5,5 +5,5 @@ - + diff --git a/assets/brand/mark.svg b/assets/brand/mark.svg index 74084b4..d2fc46b 100644 --- a/assets/brand/mark.svg +++ b/assets/brand/mark.svg @@ -5,5 +5,5 @@ - + diff --git a/assets/google_fonts/GoogleSansFlex-Medium.ttf b/assets/google_fonts/GoogleSansFlex-Medium.ttf new file mode 100644 index 0000000..c7dd0bd Binary files /dev/null and b/assets/google_fonts/GoogleSansFlex-Medium.ttf differ diff --git a/assets/google_fonts/GoogleSansFlex-Regular.ttf b/assets/google_fonts/GoogleSansFlex-Regular.ttf new file mode 100644 index 0000000..db239d0 Binary files /dev/null and b/assets/google_fonts/GoogleSansFlex-Regular.ttf differ diff --git a/assets/google_fonts/GoogleSansFlex-SemiBold.ttf b/assets/google_fonts/GoogleSansFlex-SemiBold.ttf new file mode 100644 index 0000000..b3894a8 Binary files /dev/null and b/assets/google_fonts/GoogleSansFlex-SemiBold.ttf differ diff --git a/assets/google_fonts/InstrumentSans-Medium.ttf b/assets/google_fonts/InstrumentSans-Medium.ttf deleted file mode 100644 index aa66f0c..0000000 Binary files a/assets/google_fonts/InstrumentSans-Medium.ttf and /dev/null differ diff --git a/assets/google_fonts/InstrumentSans-Regular.ttf b/assets/google_fonts/InstrumentSans-Regular.ttf deleted file mode 100644 index f20505a..0000000 Binary files a/assets/google_fonts/InstrumentSans-Regular.ttf and /dev/null differ diff --git a/assets/google_fonts/InstrumentSans-SemiBold.ttf b/assets/google_fonts/InstrumentSans-SemiBold.ttf deleted file mode 100644 index 0945c53..0000000 Binary files a/assets/google_fonts/InstrumentSans-SemiBold.ttf and /dev/null differ diff --git a/assets/google_fonts/LICENSE b/assets/google_fonts/LICENSE index 9b9bdfe..7b89960 100644 --- a/assets/google_fonts/LICENSE +++ b/assets/google_fonts/LICENSE @@ -1,11 +1,11 @@ Fonts bundled with OpenSplit, so that the app never fetches one from a third-party host at runtime. -Instrument Sans +Google Sans Flex SIL Open Font License 1.1 - InstrumentSans-Regular.ttf, InstrumentSans-Medium.ttf and - InstrumentSans-SemiBold.ttf are the exact files package:google_fonts + GoogleSansFlex-Regular.ttf, GoogleSansFlex-Medium.ttf and + GoogleSansFlex-SemiBold.ttf are the exact files package:google_fonts would otherwise download: each was fetched from the URL that package builds and checked against the SHA-256 and byte length recorded in its own font descriptors. @@ -20,7 +20,7 @@ Instrument Sans Roboto Not bundled. Flutter's web engine needs a font family named "Roboto" to exist or it downloads one from fonts.gstatic.com; pubspec.yaml satisfies - that name with Instrument Sans, so no Roboto file ships with this app. + that name with Google Sans Flex, so no Roboto file ships with this app. Nothing here is part of OpenSplit's own AGPL-3.0 licence, which covers the code and not the typefaces it sets text in. @@ -28,8 +28,7 @@ and not the typefaces it sets text in. COPYRIGHT NOTICES -Copyright 2022 The Instrument Sans Project Authors -(https://github.com/Instrument/instrument-sans) +Copyright 2015 Google LLC. All Rights Reserved. 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: diff --git a/assets/icon/icon-dark.png b/assets/icon/icon-dark.png index 0185655..7b458b1 100644 Binary files a/assets/icon/icon-dark.png and b/assets/icon/icon-dark.png differ diff --git a/assets/icon/icon-foreground.png b/assets/icon/icon-foreground.png index ee138f9..1aac5ba 100644 Binary files a/assets/icon/icon-foreground.png and b/assets/icon/icon-foreground.png differ diff --git a/assets/icon/icon-foreground.svg b/assets/icon/icon-foreground.svg index d9b0120..620fb41 100644 --- a/assets/icon/icon-foreground.svg +++ b/assets/icon/icon-foreground.svg @@ -5,5 +5,5 @@ - + diff --git a/assets/icon/icon-monochrome.png b/assets/icon/icon-monochrome.png index f6b1adb..826e868 100644 Binary files a/assets/icon/icon-monochrome.png and b/assets/icon/icon-monochrome.png differ diff --git a/assets/icon/icon.png b/assets/icon/icon.png index c501c10..98996a8 100644 Binary files a/assets/icon/icon.png and b/assets/icon/icon.png differ diff --git a/assets/splash/splash-icon-dark.png b/assets/splash/splash-icon-dark.png index 2c7c88d..4d18873 100644 Binary files a/assets/splash/splash-icon-dark.png and b/assets/splash/splash-icon-dark.png differ diff --git a/assets/splash/splash-icon.png b/assets/splash/splash-icon.png index 1ba14ee..fbfe498 100644 Binary files a/assets/splash/splash-icon.png and b/assets/splash/splash-icon.png differ diff --git a/docs/automatic-splits.md b/docs/automatic-splits.md new file mode 100644 index 0000000..10ea3bd --- /dev/null +++ b/docs/automatic-splits.md @@ -0,0 +1,92 @@ +# Automatic split remainders + +## Behaviour + +New amount and percentage splits start evenly across selected participants. +Typing a value fixes only that person's value. The unedited rows divide the +remainder evenly. The same rule applies to selected people who paid when +several people paid. Share counts remain ratios, initially one each. + +The default fields show just their values. Custom values have a small pin +button; its tooltip describes the action, **Let [name]'s value adjust**. +Tapping the pin or clearing the value lets that person share the remainder +again. **Split evenly** resets the selected participants, and **Divide evenly** +does the same for multiple payers. These actions appear only when the section +has custom values. There are no repeated Auto/Manual labels or instructional +footers. Screen-reader hints still distinguish fixed values from ones that +follow the remainder; validation errors remain visible and announced. + +While typing, the field keeps the raw text and caret; a cleared field shows +its computed value when focus leaves. Focusing a value does not fix it. + +Amounts use integer currency minor units. Automatic percentages retain the +model's six-decimal precision so rounding percentages does not skew an +otherwise equal money split. Largest-remainder allocation distributes +indivisible units in a stable order. A new expense generates its UUID locally +when the draft opens, so the preview and saved allocation use the same rounding +seed. This allocates an identifier in memory; it does not create an expense, +insert a placeholder row, enqueue a sync operation, or reserve anything on the +server. Cancel discards the unsaved draft. Save supplies that UUID to the normal +repository transaction, which writes the complete entry, history and outbox +item together. The server already accepts client-generated entry identifiers. + +Manual values never change to force a balance. If they exceed the total, if +every row is manual and a remainder is left, or if an input cannot be parsed, +the editor explains the problem and refuses saving. A selected payer must pay +more than zero; a participant may owe zero. + +Changing the total, currency, or selection recalculates automatic rows. +Currency changes reinterpret the raw typed amounts with the chosen currency's +precision; invalid manual values must be corrected explicitly. Deselected +rows do not contribute, but retain their overrides if selected again. Switching +methods retains each method's own overrides rather than translating unrelated +weights into amounts or percentages. + +When reopening an expense, all saved values of its active amount/percentage +method, and saved values for multiple payers, start manual. This preserves the +agreed allocation. The user can release individual overrides or reset that +section to automatic. No automatic/manual metadata is persisted: it describes +the editing session, not the recorded transaction. + +## Structure + +`RemainderAllocation` is a pure integer calculation shared by money, +percentages and payers. Form state stores selections and raw manual overrides; +automatic values are derived. The same derivation drives fields, previews, +validation and the saved draft. The domain's existing split and payer +validators remain the final write boundary. No API or database migration is +required. + +Material text fields, checkboxes and icon/text buttons provide interactions. +The field adapter owns only Flutter's text controller and focus lifecycle; it +does not own financial values or implement another input widget. + +## Verification + +Cover fixed-value preservation, exact conservation, stable rounding, all +manual and no-member cases, zero and invalid inputs, selection/method/total/ +currency changes, reopening, multiple payers, and preview/save equivalence. +Exercise typing and clearing while focused, reset actions, field refresh after +external form changes, and narrow layouts with large text in widget tests. +Regenerate product screenshots and their promotional derivatives after the +visible UI changes, then build and render through local Wrangler. + +Validated on 11 October 2026: + +- All 657 Flutter tests passed with `REQUIRE_BACKEND=true` against the local + Worker, including domain, form, repository and widget regressions. +- Static analysis reported no issues; the production web build succeeded. +- Actual Flutter renders and the regenerated store/landing assets were checked. +- At a 390 × 844 browser viewport, amount and multiple-payer overrides of ₹120 + left ₹180 for the other automatic row on a ₹300 expense. Reset restored + ₹150 each. A 40% override produced 60% and matching ₹120/₹180 previews. + +After simplifying the controls, all 53 targeted domain, form and widget tests +passed, including screen-reader hints, individual pins, section resets and +narrow layouts. Static analysis remained clean and the fix dry run found +nothing to fix. Flutter screenshots, promotional derivatives and the +production web bundle were regenerated. The rebuilt local Wrangler preview +confirmed ₹120/₹180 and the pin restoring ₹150 each without status helper rows. + +The live demo draft was left unsaved. Browser rendering and automated tests +cover this change; it has not been verified on a physical Android device. diff --git a/docs/brand/README.md b/docs/brand/README.md index a7f9151..03a2429 100644 --- a/docs/brand/README.md +++ b/docs/brand/README.md @@ -10,26 +10,32 @@ below; nothing here is built or served. ## Colour -The seed is `#5B5891`, a muted violet: `_seed` in +The seed is Material indigo 500, `Colors.indigo` (`#3F51B5`): `_seed` in `lib/presentation/theme.dart`, expanded by `ColorScheme.fromSeed` for both brightnesses. On Android 12 and later a wallpaper scheme replaces it unless the user turns that off, harmonised so error and the money colours shift toward the wallpaper too. Hex values are written out only where a build config cannot call Dart: the -adaptive icon background (`#E3DFFF`, light `primaryContainer`), the splash and +adaptive icon background (`#DEE0FF`, light `primaryContainer`), the splash and web loader backgrounds, the web `theme-color`, the static site's tokens at the top of `site/site.css`, and `tool/store_graphics.py`. Each is the generated value, -not the kit's: the kit quotes the light surface as `#FBF8FF`, where -`fromSeed` produces `#FCF8FF`, and the generated one wins. +from `ColorScheme.fromSeed`, including light surface `#FBF8FF` and dark +surface `#121318`. The seed is a source colour; the generated primary is +`#515B92` in light mode and `#BAC3FF` in dark mode. Money has two colours of its own, `BalanceColors` in the same file. Owed to you is a green harmonised toward the seed, and you owe is the scheme's `error`. +Automatic avatars use a stable choice of the app's primary, secondary, or +tertiary container. They follow wallpaper colours. Choosing a hue explicitly +keeps that personal choice, harmonised with the current app palette. The +picture picker offers **Automatic** to return to the theme. + ## Type -- All text is **Instrument Sans**, applied to Material 3's unmodified type scale. -- Every amount is Instrument Sans with **tabular figures**, through +- All text is **Google Sans Flex**, applied to Material 3's unmodified type scale. +- Every amount is Google Sans Flex with **tabular figures**, through `moneyStyle`, so amounts line up in a column and hold still as they change. An amount's prominence comes from the type scale step it is set in, not from a second face. @@ -48,16 +54,16 @@ shows through it. | File | Use | |---|---| -| `assets/brand/mark.svg` | brand violet | +| `assets/brand/mark.svg` | generated brand primary (`#515B92`) | | `assets/brand/mark-mono.svg` | `currentColor`, tinted at runtime; the only variant bundled into the app (`BrandMark`) | | `assets/brand/mark-neutral.svg` | pre-stroked `#E4E1E9`, a light neutral | -| `assets/brand/mark-on-dark.svg` | pre-stroked `#C4C0FF`, for dark surfaces | -| `assets/brand/mark-on-primary-container.svg` | pre-stroked `#E3DFFF` | +| `assets/brand/mark-on-dark.svg` | pre-stroked `#BAC3FF`, for dark surfaces | +| `assets/brand/mark-on-primary-container.svg` | pre-stroked `#DEE0FF` | | `assets/brand/mark-1024-light.png`, `-dark.png` | rasters for the icon and splash generators | | `assets/brand/lockup-horizontal.svg`, `lockup-vertical.svg` | mark and wordmark: "Open" regular, "Split" semibold | | `site/favicon.svg` | the browser tab | -The lockups name Instrument Sans rather than carrying outlines, so convert the +The lockups name Google Sans Flex rather than carrying outlines, so convert the text to outlines before using one anywhere the font is not loaded: a store listing, a social preview. @@ -71,8 +77,9 @@ Generated, never edited by hand: | Command | Writes | From | |---|---|---| +| `python3 tool/brand_artwork.py` | the PNG sources in `assets/brand/`, `assets/icon/` and `assets/splash/` | the canonical mark SVG and the generated palette | | `dart run flutter_launcher_icons` | Android, iOS and web icons | `assets/icon/`, configured in `flutter_launcher_icons.yaml` | -| `dart run tool/brand_icons.dart` | the favicon and the notification icon, after the command above | the transparent artwork in `assets/icon/` | +| `dart run tool/brand_icons.dart` | root web icons, the favicon and the notification icon, after the command above | the transparent artwork in `assets/icon/` | | `dart run flutter_native_splash:create` | the native splash screens | `assets/splash/`, configured in `flutter_native_splash.yaml` | | `flutter test tool/screenshots_test.dart` | `site/store/screenshot-*.png` and `web-app.png` | the real screens over demo data | | `python3 tool/store_graphics.py` | `site/store/og-card.png` and `feature-graphic.jpg`, and `site/email/wordmark.png` for the sign-in email | the balances screenshot, the mark's geometry and the bundled font | @@ -91,9 +98,23 @@ splash for the same reason. | Feature graphic | 1024 × 500, JPEG | `site/store/feature-graphic.jpg` | | Phone screenshots | 1080 × 1920, PNG | `site/store/screenshot-1-groups.png` to `-5-insights.png`, in that order | -All of it is generated (above), and the listing is uploaded by hand: the release -lane sends the bundle and its notes, never images. Feature graphics are JPEG -because Play rejects a PNG with an alpha channel, even a fully opaque one. +All of it is generated (above). To package the committed assets and Cider notes: + +```sh +dart run tool/play_metadata.dart --output=build/play-metadata +``` + +The tool uses Fastlane's `en-US` metadata layout, resizing the icon to an opaque +512 × 512 PNG. The release workflow prepares this tree before building and +saves it as a separate release artifact. Fastlane uploads the artwork with the +bundle and notes, skipping unchanged images by checksum. The screenshots replace the +phone gallery in their listed order. Listing images apply across Play tracks, +even when the bundle is sent to closed testing; listing text is left alone. + +The output must be new or empty, so stale images cannot slip into a release. +The tool checks image dimensions and opacity and fails if anything is missing. +Feature graphics are JPEG because Play rejects a PNG with an alpha channel, +even a fully opaque one. The screenshots are the app itself, rendered by a widget test with the bundled fonts, so a UI change is a rerun rather than a redesign. The demo data is a @@ -109,8 +130,9 @@ point at the committed assets above. They are where the brand started, not where the site is now: the landing page, the document pages and the store images have since been redesigned in code, in -`site/` and the two generators above. The mark, colours and type they set out -are unchanged. +`site/` and the generators above. The October 2026 update keeps the mark and +uses indigo with Google Sans Flex. The original canvases remain historical +references; this document describes the shipped identity. | File | What it is | |---|---| diff --git a/docs/runbook.md b/docs/runbook.md index d4f00b6..6044f35 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -87,7 +87,11 @@ cd server && pnpm dev # the Worker, on localhost:8787 Open **`http://localhost:8787/app`**, with `localhost` rather than `127.0.0.1`, because the session cookie and `APP_ORIGIN` in `.dev.vars` are for -`localhost`. Rebuild after changing Dart code; this path has no hot reload. +`localhost`. This path has no hot reload. After changing Dart code, stop +`pnpm dev`, rebuild, and start it again. The builder replaces `build/web`; +restarting Wrangler refreshes its asset manifest instead of retaining 404s for +the replaced files. If the browser offers an update, use **Restart** to switch +its service worker to the rebuilt release. - **Signing in.** Continue as a guest, or use an email address: the code is printed in the `pnpm dev` terminal. Google sign-in has no credentials @@ -322,13 +326,15 @@ A push to `main` runs `.github/workflows/release.yml`: 1. CI, the same jobs as on a pull request. 2. The resource-id check, before anything slow. -3. The build number, and Play's release notes from `CHANGELOG.md` (see - *Versions*). +3. The build number, and Play metadata: release notes from `CHANGELOG.md` + (see *Versions*) plus the committed icon, feature graphic and screenshots. 4. The web bundle and the signed Android bundle, both carrying that number, - uploaded as artifacts. + uploaded as artifacts alongside the prepared Play metadata. 5. `wrangler d1 migrations apply opensplit --remote`: whatever is pending. 6. `wrangler deploy`: the script and the bundle, together, as one version. -7. A wait for `/api/health`, then Play closed testing, with the notes. +7. A wait for `/api/health`, then Play closed testing, with the notes and listing + artwork. Fastlane skips unchanged images by checksum and preserves listing + text. The listing artwork is shared across tracks. 8. A `v` tag, the first time a build of that version ships. D1 migrations are applied before the Worker that needs them goes live. @@ -402,6 +408,15 @@ rest on a last line. The notes are in `en-US`, the store listing's language, because Play refuses notes in a language the listing lacks. Add a language to the listing, and the workflow has to write that language too. +**Play's artwork.** `tool/play_metadata.dart` packages the notes and the current +committed artwork into a fresh `en-US` supply tree. It checks all inputs before +writing, resizes the icon to 512 × 512 and retains the feature graphic and +screenshots byte-for-byte. The workflow passes that directory as +`PLAY_METADATA_DIR`; the lane refuses incomplete artwork. See +`docs/brand/README.md` for the source files and generation commands. Run +`bundle exec ruby test/tool/fastlane_test.rb` to exercise the upload configuration +locally without contacting Play. + **Tags.** The first build of each version tags its commit `v1.2.0`, and later builds leave the tag where it was. The compare links at the foot of `CHANGELOG.md` point at those tags. `v1.0.0` marks the first build that reached @@ -495,6 +510,12 @@ Anything else means links open in a browser instead of the app, silently. ## Play Console +The release service account needs the app-level +[Manage store presence](https://support.google.com/googleplay/android-developer/answer/9844686) +permission as well as access to release on testing tracks, so Fastlane can +update the listing artwork. The upload does not change listing text or promote +the bundle to production. Play review and availability still apply. + Under *App content*, the three URLs, each a real static file answering 200 with no redirect, and the same constants the app's Settings screen opens (`lib/config.dart`): diff --git a/docs/ui-ux.md b/docs/ui-ux.md new file mode 100644 index 0000000..442fbd9 --- /dev/null +++ b/docs/ui-ux.md @@ -0,0 +1,259 @@ +# OpenSplit UI and UX + +Design review: 10 October 2026. The selected direction is **indigo and Google +Sans Flex**, rendered in the real Flutter expense editor alongside violet and +teal alternatives. The slashed O remains the product mark. + +## Product journey + +OpenSplit keeps a shared record of spending and payments. Its main decisions +are who paid, who shares the cost, and who still owes whom. The visual hierarchy +should help people verify those decisions before recording them. + +| Journey | Presentation and purpose | +| --- | --- | +| Start, sign in, save a guest account | A clear primary action, ordinary filled fields, and outlined alternative identity actions. Recovery is available from Account and Settings. | +| Groups and archive | Group identity, name, and balance remain scannable. Standard FABs create groups; navigation becomes a drawer or rail as space permits. | +| Group journal | A tonal cover identifies the group. Expenses are grouped by day; amounts and payer summaries remain distinct. Expenses and balances use stock tabs, with both panels visible on wide screens. | +| Add or edit expense | Amount and currency form the headline. Description, category, and day follow. Separate payer and split cards group the two money decisions. Optional time is in a stock expansion tile. | +| People and invitations | Member avatars use the active palette by default. Membership actions, placeholders, and invite sheets retain standard lists, buttons, menus, and sheets. | +| Balances and settlement | Credit and debit use harmonised semantic colours with explanatory text. Settlement shares the expense editor's amount component, followed by payer and payee fields. Currencies remain separate. | +| Insights and activity | Existing totals, charts, and change history provide context; the same type scale and theme apply. | +| Account, group settings, and appearance | Readable lists lead to focused editors and confirmation dialogs. Appearance follows the device or a user override. Settings explains whether a device palette is available. | + +## Component policy + +Use `package:material_ui`, the current decoupled Material widget library already +adopted by the repository. Retain its defaults for shape, interaction, motion, +elevation, and type scale. Use the available Material 3 Expressive icon-button +variant. Further Expressive components can be adopted as that library ships +stable implementations; do not emulate their behaviour with custom controls. + +- **Fields:** ordinary text fields and `DropdownMenu` share a filled decoration + with the stock underline indicator. A dropdown has its own component theme, + so both themes are configured together. Filled and outlined fields are both + valid Material choices; this app chooses one consistent default. +- **Transaction headline:** `AmountCard` composes a stock filled card, text + field, outlined currency button, and `SearchAnchor`. The amount deliberately + keeps an always-floating **Amount** label, a currency-specific zero + placeholder and the stock underline indicator. The placeholder does not + prefill a value. Both expense and settlement use the same presentation and + the existing decimal formatter. +- **Actions:** filled buttons commit or advance a main task; tonal buttons + perform supporting actions; outlined buttons offer alternatives; text + buttons perform low-emphasis contextual actions. FABs create primary content. + Error colour identifies failure or a consequential destructive confirmation. +- **Selection:** choice chips select a payer; checkboxes select participants. + Split methods use a segmented button when they fit and a stock dropdown on + narrow screens or with large text. No custom popup or selection state logic + is introduced. +- **Supporting details:** category and day are action chips, with Material + sheets and pickers. The expense's optional time is disclosed progressively; + its default timestamp and saved values keep their existing meaning. +- **Feedback:** a failed expense save appears immediately in a snackbar and + remains explained inline. Loading, empty, offline, unsynced, and conflict + states retain their existing flows and controls. + +Contained account, sync, and conflict notices use `Card.filled` with its stock +shape, an icon, a title, supporting text, and an `OverflowBar` of Material +buttons. Failed writes use `errorContainer` and `onErrorContainer`; advice and +refresh notices use the card's surface role. The action bar stacks at narrow +widths or large text. Rectangular `MaterialBanner` is reserved for a notice +attached to the screen's top, rather than inset among cards. + +Group rows use the same cover artwork as the detail header across the full +background, with the pattern softened beneath the name and standing. Opening a group shares +its cover, avatar and name through Flutter's built-in `Hero`, including the +return journey. The cover crops one fixed composition so its category icons do +not reflow during the flight. Its segmented row corners open into the header. The +expanded header groups name and standing beside the avatar; the collapsed +toolbar retains the name without duplicating its Hero. + +The group picture opens into its larger editable preview. Existing expenses +share their category, description and amount with the editor. Suggested +payments share payer, recipient and amount with the settlement fields. Text +flights interpolate explicit Material styles and carry only the text, keeping +editable controls on their route. Local forms initialize synchronously when +their saved data is ready, making these endpoints available on the first frame; +deep links still wait for missing local data. Only the active navigation branch +participates, and reduced motion disables every shared flight. The existing +router, deep links, Back behaviour, page transitions, and fade-through between +top-level destinations remain in place. No second navigation stack or custom +animation controller is added. + +The navigation shell and group scaffold keep the same widget ancestry across +their responsive breakpoints. This preserves the mounted navigator and app-bar +focus nodes while a route restores keyboard focus. Desktop mouse and route +resize checks exercise the transition and verify that its controls stay mounted. +The outer app group uses Flutter's built-in `WidgetOrderTraversalPolicy` to +order route scopes without measuring their transitional geometry. Each screen +keeps the standard `ReadingOrderTraversalPolicy`, so Tab follows its visual +layout. Native view-focus restoration and app-bar keyboard order are included +in the regression check. + +## Colour and type + +Material indigo 500, `Colors.indigo` (`#3F51B5`), generates the complete light +and dark schemes. It is a source colour, not a literal colour to scatter across +widgets. The generated light +primary is `#515B92`; the dark primary is `#BAC3FF`. + +Use surface containers for grouping and depth. Use primary for key actions, +secondary containers for the amount headline, and the three accent containers +for automatic avatars. Always pair a container with its matching `on` colour. +Avoid adding arbitrary gradients, glows, or textures to the app's Material +surfaces. The static website keeps its existing composition in the same palette. + +Automatic avatar colour is stable for an identity and follows the active app +scheme, including wallpaper colours. Explicit avatar hues remain personal +choices harmonised with the active scheme. **Automatic** clears a previous hue +without changing the picture. Group motifs use the cover's matching foreground +at low opacity. + +Google Sans Flex uses the unchanged Material type scale, with the 400, 500, and +600 faces bundled locally. Their byte lengths and SHA-256 values were checked +against the descriptors in `google_fonts` 9.0.0. The full OFL and the fonts' +embedded copyright notice travel with the bundle. Amounts retain tabular +figures. The web engine's fallback family also uses the bundled face; runtime +font fetching remains disabled. + +The website, email template, launcher, web manifest, and native/web splash +colours follow the same generated roles. See [brand generation](brand/README.md) +for the commands that keep those surfaces in step. + +## Adaptive colours and Samsung + +The Android plugin reads the OS's five tonal palettes, encoded as an Int32List. +OpenSplit converts those to light and dark schemes through `dynamic_color`. +The device palette is now re-read on foreground resume and system brightness +changes. A palette PlatformException no longer skips the separate system-accent +fallback; unsupported platforms retain the brand scheme. The opt-out remains +persistent. Settings previews the palette for its current brightness and offers +**Check again** when native colours are unavailable. + +On the Galaxy F54, check **Samsung Settings → Wallpaper and style → Colour +palette**, then return to OpenSplit with device colours enabled in the app. +Repeat after choosing a visibly different palette. A web preview cannot test +Android's native palette. The device was not attached during this review, so +these code fixes do not establish the cause of the reported F54 behaviour. + +## Validation + +Automated coverage includes all four split methods, multiple payers, saved +values, optional time, decimal-comma settlement, and save-error visibility. +Responsive editor checks cover 360 × 640 at 100% and 200% text and 1280 × 900. +Theme checks cover launch resources, readable balance colours, and avatar +foreground contrast of at least 4.5:1 in light/dark and alternate palettes. +Native-channel tests cover palette conversion, absence, failures, persistent +opt-out, and a changed palette on resume. + +The actual app is built with `dart run tool/build_web.dart` and served through +`cd server && pnpm dev` on the same localhost origin. This preserves the real +Worker, OPFS database, session, and headers. The committed product screenshots +are rendered by `flutter test tool/screenshots_test.dart` from repository demo +data. Browser verification and native-device limits are reported with the +change; automated tests do not replace TalkBack or physical One UI acceptance. + +Recorded validation for this change: + +- Flutter suite with the local Worker running: **607 passed, no skips**, + including the real API adapter tests. A run without the Worker also passed + with the environment-dependent tests skipped. +- Dart analyzer: **no issues**; automatic-fix dry run: **nothing to fix**. +- Production Flutter web and Android debug builds: **successful**. +- Bundled-font licensing, native/web launch consistency, and generated product + screenshot checks: **passed**. +- Email template: **2 tests passed**; site/template formatting: **passed**. +- Live Wrangler flow: saved a ₹2,400 expense split by two people, verified the + ₹1,200 balance and prefilled settlement, then recorded the local test payment + and verified **All settled up**. Light/dark phone and desktop renders were + captured under `build/ui-review/`. +- Live route exit while resizing from phone to desktop: **no new browser + errors**, after checking the active rebuilt service-worker release. + +Follow-up validation, 11 October 2026: notice actions stack and dismiss at +200% text on a 320dp phone; the group avatar flies across the nested navigator +in both directions and is disabled for reduced motion. The focused UI and +launch checks passed (65 tests), as did colour and native-palette checks +(31 tests, including repeated launch checks), screenshot rendering, and the +two email tests. Static analysis and the production web build passed. The local +Wrangler preview was checked in light/dark phone layouts and light desktop, +including opening and returning from a group, with no browser errors. + +Shared-identity and input follow-up, 11 October 2026: **617 Flutter tests passed +with `REQUIRE_BACKEND=true`**, including the real local Worker API and serving +checks. First-frame expense and settlement flights, the group picture preview, +group name/cover flights in both directions, reduced motion, long names on a +320dp phone at 200% text, and tapping the unfocused amount placeholder are +covered. Static analysis found no issues; the automatic-fix dry run found +nothing to fix. The production web build and regenerated product screenshots +passed. Local Wrangler is restarted after rebuilding its replaced assets tree; +the browser's update prompt switches to the new release before visual review. +Live light/dark phone and light desktop captures are under `build/ui-review/`. + +## References + +- [Flutter Material design documentation](https://docs.flutter.dev/ui/design/material) +- [Current Material UI package](https://pub.dev/packages/material_ui) +- [Material colour roles](https://api.flutter.dev/flutter/material/ColorScheme-class.html) +- [Flutter's Material 3 Expressive tracking issue](https://github.com/flutter/flutter/issues/168813) +- [Google Sans Flex](https://design.google/library/google-sans-flex-font) +- [Dynamic colour plugin](https://pub.dev/packages/dynamic_color) +- [Samsung wallpaper and colour palette settings](https://www.samsung.com/ae/support/mobile-devices/how-to-customize-a-wallpaper-and-color-palette/) + + +## Payment editing and full-card artwork + +Group list artwork fills each complete Material row. A transparency Material +above it keeps stock ink interactions visible. Pattern opacity is softened +under the text, and stock Hero animations interpolate that treatment and the +segmented outline into the full header. Colours remain generated Material +container roles; the opacity mask adds no new palette colours. + +Recorded settlements keep the existing `/g/:groupId/e/:entryId` URL and open +the payment editor. It shares the amount, member menus and optional details +with Settle up, offers date, optional time and payment note, and keeps the +shared entry history. UPI handoff and outstanding-debt suggestions belong to +recording a new payment and do not appear while editing a recorded one. +Payment titles are derived from the sender and recipient. Notes and older +stored descriptions remain available without requiring a redundant name. + +Local and server writes prevent changing an existing entry's kind. Settlement +composition and server writes enforce one sender and one distinct recipient, +with both sides matching the total. A malformed older payment can still be +soft-deleted on the server. These are validation changes; no database columns, +wire fields or migration are added. Existing misclassified data is not guessed +at or rewritten. + +Regression checks cover unchanged payment saves, amount and note changes, +date/time preservation, date-only changes, stale edits, soft deletion and +history, shared full-row artwork, and light/dark layouts at 320px and 200% text. +At larger text sizes, the entry's balance explanation moves beneath its title +and wraps instead of overflowing the trailing amount slot. + +Validation: all 625 Flutter tests passed with `REQUIRE_BACKEND=true`, and all +280 server tests passed. Static analysis, server type checking, the production +web build and regenerated Flutter product screenshots passed. The local +Wrangler preview confirmed the full-row artwork and payment editor at phone +and desktop sizes; saving the demo payment unchanged kept its payment kind +and fully settled balances. Dark-mode artwork was also rendered in the browser. + +Amount and percentage splits now start with automatic values. Manual overrides +remain fixed while automatic rows divide the remainder; multiple payers use +the same calculation. Saved allocations start manual when reopened. See +[automatic split remainders](automatic-splits.md) for the interaction rules, +rounding guarantees, state design and regression scope. + +The split editor keeps this behaviour quiet: default fields show only values, +custom values have a small pin action, and the section can be reset with +**Split evenly** (**Divide evenly** for payers). Repeated Auto/Manual helper +labels and instructional footers are removed. Tooltips and screen-reader hints +explain which values remain fixed without adding visual rows. + +Final branch verification on 11 October 2026: all 657 Flutter tests passed +against the running local Worker, and all 280 backend tests passed. Dart +formatting and analysis, repository lint and server type checking passed. +The final web bundle and regenerated product assets were rendered through +local Wrangler, and the final Android debug APK compiled successfully. +Physical Galaxy F54 / One UI palette acceptance remains +unverified. diff --git a/fastlane/Fastfile b/fastlane/Fastfile index bf9130d..3320f61 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -71,13 +71,17 @@ platform :android do ) UI.user_error!("Build the signed release AAB first") unless File.file?(bundle) - # Testers' "What's new", from CHANGELOG.md: a directory in supply's - # metadata layout, holding /changelogs/default.txt and nothing - # else, which the release workflow writes with tool/release_notes.dart. - # Unset, a release goes up without notes, as one from a laptop would. - notes = ENV["PLAY_RELEASE_NOTES_DIR"].to_s - unless notes.empty? || Dir.glob(File.join(notes, "*", "changelogs", "default.txt")).any? - UI.user_error!("PLAY_RELEASE_NOTES_DIR has no /changelogs/default.txt") + # The complete supply tree: Cider notes plus the committed listing artwork. + # Prepare it with tool/play_metadata.dart before any production upload. + metadata = File.expand_path(ENV.fetch("PLAY_METADATA_DIR")) + required_files = [ + "en-US/changelogs/default.txt", + "en-US/images/icon.png", + "en-US/images/featureGraphic.jpg" + ] + unless required_files.all? { |path| File.file?(File.join(metadata, path)) } && + Dir.glob(File.join(metadata, "en-US/images/phoneScreenshots/*.png")).size == 5 + UI.user_error!("Prepare the complete PLAY_METADATA_DIR with tool/play_metadata.dart") end if priority >= 4 @@ -95,11 +99,12 @@ platform :android do json_key: credentials, aab: bundle, skip_upload_apk: true, - metadata_path: notes.empty? ? nil : notes, + metadata_path: metadata, skip_upload_metadata: true, - skip_upload_changelogs: notes.empty?, - skip_upload_images: true, - skip_upload_screenshots: true, + skip_upload_changelogs: false, + skip_upload_images: false, + skip_upload_screenshots: false, + sync_image_upload: true, # Fail visibly rather than silently changing review semantics on retry. rescue_changes_not_sent_for_review: false ) diff --git a/flutter_launcher_icons.yaml b/flutter_launcher_icons.yaml index f027d4f..d179c96 100644 --- a/flutter_launcher_icons.yaml +++ b/flutter_launcher_icons.yaml @@ -11,7 +11,7 @@ flutter_launcher_icons: # primaryContainer in the light scheme. A flat colour rather than an image # layer: the background of an adaptive icon is parallaxed and cropped by the # launcher, and there is nothing in this one that needs to survive that. - adaptive_icon_background: "#E3DFFF" + adaptive_icon_background: "#DEE0FF" adaptive_icon_foreground: "assets/icon/icon-foreground.png" # Android 13+ themed icons, where the launcher recolours a monochrome layer to # the user's wallpaper palette. @@ -33,10 +33,10 @@ flutter_launcher_icons: # This also rewrites web/manifest.json in place, keeping the fields it does not # own. background_color and theme_color are both ColorScheme.surface: the # browser paints theme_color immediately above the app bar, which sits on the - # surface, so the brand primary would draw a violet band against a near-white + # surface, so the brand primary would draw a coloured band against a near-white # bar. A test holds both against the running theme. web: generate: true image_path: "assets/icon/icon.png" - background_color: "#FCF8FF" - theme_color: "#FCF8FF" + background_color: "#FBF8FF" + theme_color: "#FBF8FF" diff --git a/flutter_native_splash.yaml b/flutter_native_splash.yaml index 2ff25b3..94e5bb8 100644 --- a/flutter_native_splash.yaml +++ b/flutter_native_splash.yaml @@ -4,13 +4,10 @@ # this runs Dart — the same duplication the web splash has, and pinned the same # way, by a test. # -# #FCF8FF is ColorScheme.surface in the light scheme, #131318 in the dark. -# The brand kit quotes the light one as #FBF8FF; this is what -# ColorScheme.fromSeed actually produces, and the generated value is the one -# the app is built against. +# #FBF8FF is ColorScheme.surface in the light scheme, #121318 in the dark. flutter_native_splash: - color: "#FCF8FF" - color_dark: "#131318" + color: "#FBF8FF" + color_dark: "#121318" image: assets/splash/splash-icon.png image_dark: assets/splash/splash-icon-dark.png diff --git a/lib/application/entry_form.dart b/lib/application/entry_form.dart index 7cfb38d..cbeb0ca 100644 --- a/lib/application/entry_form.dart +++ b/lib/application/entry_form.dart @@ -5,8 +5,9 @@ library; import 'dart:async'; import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:opensplit_api/opensplit_api.dart' show SplitKind; +import 'package:opensplit_api/opensplit_api.dart' show EntryKind, SplitKind; import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:uuid/uuid.dart'; import '../data/local/database.dart'; import '../domain/calendar_date.dart'; @@ -17,6 +18,7 @@ import '../domain/fx/fx_quote.dart'; import '../domain/models/entry.dart'; import '../domain/money_format.dart'; import '../domain/split/allocation.dart'; +import '../domain/split/remainder.dart'; import '../domain/split/splitter.dart'; import 'ledger_providers.dart'; import 'local_providers.dart'; @@ -36,6 +38,10 @@ abstract class EntryFormState with _$EntryFormState { /// The expense as it was when the editor opened; null for a new one. /// Saving refuses if it has changed since. Entry? editing, + + /// Generated locally before previewing; nothing is stored until Save. + /// The saved entry uses this identity to keep rounding consistent. + String? newEntryId, required String currencyCode, @Default('') String amount, @Default('') String description, @@ -58,13 +64,13 @@ abstract class EntryFormState with _$EntryFormState { /// Whole shares per person, for a split by shares. One when unset. @Default({}) Map shareCounts, - /// Typed per person, for a split by amounts or percentages. + /// Manual overrides only. An absent member shares the remainder. @Default({}) Map exactAmounts, @Default({}) Map percents, @Default({}) Set payers, @Default(false) bool severalPayers, - /// Typed per payer, when several people paid. + /// Manual payer overrides. Unedited payers share the remainder. @Default({}) Map paidAmounts, @Default(false) bool saving, String? error, @@ -77,6 +83,7 @@ abstract class EntryFormState with _$EntryFormState { factory EntryFormState.blank(GroupLedger ledger, {required DateTime now}) { final me = ledger.me?.id ?? ledger.members.firstOrNull?.id; return EntryFormState( + newEntryId: const Uuid().v4(), currencyCode: ledger.group.defaultCurrency, day: calendarDay(now), // It happened now, here, unless the time is taken off. @@ -103,29 +110,38 @@ abstract class EntryFormState with _$EntryFormState { participants: {for (final share in entry.shares) share.memberId}, shareCounts: { for (final share in entry.shares) - if (share.weightMicros case final weight?) - share.memberId: (weight / weightScale).round(), + if (entry.row.splitKind == SplitKind.shares) + if (share.weightMicros case final weight?) + share.memberId: (weight / weightScale).round(), }, exactAmounts: { - for (final share in entry.shares) - share.memberId: typed(share.amountMinor), + if (entry.row.splitKind == SplitKind.exact) + for (final share in entry.shares) + share.memberId: typed(share.amountMinor), }, percents: { - for (final share in entry.shares) - if (share.weightMicros case final weight?) - share.memberId: formatPercentMicros(weight), + if (entry.row.splitKind == SplitKind.percent) + for (final share in entry.shares) + share.memberId: switch (share.weightMicros) { + final weight? => formatPercentMicros(weight), + null => '', + }, }, payers: {for (final payer in entry.payers) payer.memberId}, severalPayers: entry.payers.length > 1, paidAmounts: { - for (final payer in entry.payers) - payer.memberId: typed(payer.amountMinor), + if (entry.payers.length > 1) + for (final payer in entry.payers) + payer.memberId: typed(payer.amountMinor), }, ); } bool get isEditing => editing != null; + /// Identity shared by preview rounding and the eventual write. + String? get allocationSeed => editing?.id ?? newEntryId; + /// The total in [currency], once what was typed reads as one. int? totalIn(Currency currency) { final total = currency.parseToMinor(amount); @@ -134,22 +150,15 @@ abstract class EntryFormState with _$EntryFormState { int shareCountOf(String memberId) => shareCounts[memberId] ?? 1; - /// What each person ends up owing, for splits that can be worked out before - /// anything else is typed. Null for the rest, or until there is a total. + /// What each person owes, from the same rule and seed used when saving. Map? preview(Currency currency) { final total = totalIn(currency); if (total == null || participants.isEmpty) return null; - final spec = switch (splitKind) { - SplitKind.equal => EqualSplit(participants.toList()), - SplitKind.shares => SharesSplit({ - for (final id in participants) id: shareCountOf(id), - }), - _ => null, - }; + final spec = _split(currency); if (spec == null) return null; try { return { - for (final share in spec.resolve(total)) + for (final share in spec.resolve(total, seed: allocationSeed)) share.memberId: share.amountMinor, }; } on SplitException { @@ -157,6 +166,99 @@ abstract class EntryFormState with _$EntryFormState { } } + /// Selected participants' amounts, preserving manual overrides. + RemainderAllocation? exactAllocation(Currency currency) => + _moneyAllocation(currency, participants, exactAmounts); + + /// Selected participants' percentages, which must total exactly 100. + RemainderAllocation percentAllocation() => RemainderAllocation( + total: PercentSplit.fullPercentMicros, + memberIds: participants, + overrides: { + for (final entry in percents.entries) + entry.key: parsePercentMicros(entry.value), + }, + seed: allocationSeed, + ); + + /// Selected payers' amounts, using the same remainder calculation. + RemainderAllocation? paidAllocation(Currency currency) => + _moneyAllocation(currency, payers, paidAmounts); + + RemainderAllocation? _moneyAllocation( + Currency currency, + Set members, + Map overrides, + ) { + final total = totalIn(currency); + if (total == null) return null; + return RemainderAllocation( + total: total, + memberIds: members, + overrides: { + for (final entry in overrides.entries) + entry.key: currency.parseToMinor(entry.value), + }, + seed: allocationSeed, + ); + } + + /// An actionable split error, also displayed beside the split inputs. + String? splitError(Currency currency) => switch (splitKind) { + SplitKind.exact => allocationError(exactAllocation(currency), currency), + SplitKind.percent => allocationError( + percentAllocation(), + currency, + percent: true, + ), + SplitKind.equal || SplitKind.shares => + participants.isEmpty ? 'Choose at least one participant.' : null, + SplitKind.unknownDefaultOpenApi => 'This split method is not supported.', + }; + + /// An actionable payer error, including zero amounts that cannot be paid. + String? paidError(Currency currency) { + if (!severalPayers) { + return payers.isEmpty ? 'Choose who paid.' : null; + } + final allocation = paidAllocation(currency); + final error = allocationError(allocation, currency, paying: true); + if (error != null) return error; + if (allocation?.values.values.any((amount) => amount <= 0) ?? false) { + return 'Each selected payer must pay more than zero. ' + 'Remove a payer or adjust the amounts.'; + } + return null; + } + + /// Describes why an allocation cannot yet be recorded. + static String? allocationError( + RemainderAllocation? allocation, + Currency currency, { + bool percent = false, + bool paying = false, + }) { + if (allocation == null) return null; + String shown(int value) => percent + ? '${formatPercentMicros(value.abs())}%' + : formatMoney(currency, value.abs()); + return switch (allocation.problem) { + RemainderProblem.noMembers => + paying ? 'Choose who paid.' : 'Choose at least one participant.', + RemainderProblem.invalidOverrides => + percent + ? 'Enter a valid percentage or clear this value.' + : 'Enter a valid amount or clear this value.', + RemainderProblem.overallocated => + '${shown(allocation.remaining)} over ${percent ? '100%' : 'the total'}. ' + 'Reduce a value or divide evenly.', + RemainderProblem.unallocated => + '${shown(allocation.remaining)} left to assign. ' + 'Adjust a value or clear one to share the rest.', + null => null, + }; + } + /// The expense this form describes in [groupId], or what is still missing /// from it. DraftCheck check( @@ -165,6 +267,9 @@ abstract class EntryFormState with _$EntryFormState { FxQuote? fx, String? deviceZone, }) { + if (editing?.row.kind == EntryKind.settlement) { + return const DraftIncomplete('Use the payment editor for this payment.'); + } final total = totalIn(currency); if (total == null) { return DraftIncomplete( @@ -174,12 +279,21 @@ abstract class EntryFormState with _$EntryFormState { 'places.', ); } + final splitProblem = splitError(currency); + if (splitProblem != null) return DraftIncomplete(splitProblem); final split = _split(currency); if (split == null) { return const DraftIncomplete( 'Check the split — some amounts are missing.', ); } + try { + split.resolve(total, seed: allocationSeed); + } on SplitException catch (error) { + return DraftIncomplete(error.message); + } + final paidProblem = paidError(currency); + if (paidProblem != null) return DraftIncomplete(paidProblem); final paid = _paid(currency, total); if (paid == null) { return const DraftIncomplete( @@ -215,31 +329,22 @@ abstract class EntryFormState with _$EntryFormState { final ids = participants.toList(); if (ids.isEmpty) return null; - Map? each( - int? Function(String text) parse, - Map typed, - ) { - final values = {}; - for (final id in ids) { - final value = parse(typed[id] ?? ''); - if (value == null) return null; - values[id] = value; - } - return values; - } - return switch (splitKind) { SplitKind.equal => EqualSplit(ids), SplitKind.shares => SharesSplit({ for (final id in ids) id: shareCountOf(id), }), - SplitKind.exact => switch (each(currency.parseToMinor, exactAmounts)) { - final amounts? => ExactSplit(amounts), - null => null, + SplitKind.exact => switch (exactAllocation(currency)) { + final allocation? when allocation.isBalanced => ExactSplit( + allocation.values, + ), + _ => null, }, - SplitKind.percent => switch (each(parsePercentMicros, percents)) { - final percents? => PercentSplit(percents), - null => null, + SplitKind.percent => switch (percentAllocation()) { + final allocation when allocation.isBalanced => PercentSplit( + allocation.values, + ), + _ => null, }, // A split rule from a newer server: saving would have to guess it. SplitKind.unknownDefaultOpenApi => null, @@ -251,13 +356,12 @@ abstract class EntryFormState with _$EntryFormState { final only = payers.firstOrNull; return only == null ? null : {only: total}; } - final amounts = {}; - for (final id in payers) { - final amount = currency.parseToMinor(paidAmounts[id] ?? ''); - if (amount == null || amount <= 0) return null; - amounts[id] = amount; - } - return amounts.isEmpty ? null : amounts; + final allocation = paidAllocation(currency); + return allocation != null && + allocation.isBalanced && + allocation.values.values.every((amount) => amount > 0) + ? allocation.values + : null; } } @@ -274,8 +378,9 @@ class EntryForm extends _$EntryForm { final _ratesAsked = {}; @override - Future build(String groupId, {String? entryId}) { + FutureOr build(String groupId, {String? entryId}) { final seeded = Completer(); + EntryFormState? ready; void seed() { if (seeded.isCompleted) return; final ledger = ref.read(groupLedgerProvider(groupId)); @@ -283,20 +388,26 @@ class EntryForm extends _$EntryForm { if (ledger == null || currencies == null) return; if (entryId == null) { - seeded.complete(EntryFormState.blank(ledger, now: DateTime.now())); + ready = EntryFormState.blank(ledger, now: DateTime.now()); + seeded.complete(ready); return; } final entry = ledger.entries.where((e) => e.id == entryId).firstOrNull; if (entry != null) { - seeded.complete( - EntryFormState.of(entry, currencies[entry.row.currency]), - ); + ready = EntryFormState.of(entry, currencies[entry.row.currency]); + seeded.complete(ready); } } ref.listen(groupLedgerProvider(groupId), (_, _) => seed()); ref.listen(currenciesProvider, (_, _) => seed()); seed(); + // Saved local data needs no loading frame. This also lets route Heroes + // connect to the actual fields as soon as the editor is mounted. + if (ready case final form?) { + unawaited(_askForRateIfMissing(form)); + return form; + } return seeded.future.then((form) { unawaited(_askForRateIfMissing(form)); return form; @@ -305,8 +416,10 @@ class EntryForm extends _$EntryForm { EntryFormState get _form => state.requireValue; - void _change(EntryFormState Function(EntryFormState form) change) => - state = AsyncData(change(_form).copyWith(error: null)); + void _change(EntryFormState Function(EntryFormState form) change) { + if (_form.saving) return; + state = AsyncData(change(_form).copyWith(error: null)); + } void setAmount(String text) => _change((form) => form.copyWith(amount: text)); @@ -379,14 +492,32 @@ class EntryForm extends _$EntryForm { ); void setExactAmount(String memberId, String text) => _change( - (form) => - form.copyWith(exactAmounts: {...form.exactAmounts, memberId: text}), + (form) => form.copyWith( + exactAmounts: _override(form.exactAmounts, memberId, text), + ), ); void setPercent(String memberId, String text) => _change( - (form) => form.copyWith(percents: {...form.percents, memberId: text}), + (form) => form.copyWith(percents: _override(form.percents, memberId, text)), ); + /// Releases all overrides of the current amounts or percentage method. + void resetSplit() => _change( + (form) => switch (form.splitKind) { + SplitKind.exact => form.copyWith(exactAmounts: {}), + SplitKind.percent => form.copyWith(percents: {}), + _ => form, + }, + ); + + Map _override( + Map previous, + String id, + String text, + ) => text.trim().isEmpty + ? ({...previous}..remove(id)) + : {...previous, id: text}; + /// The one person who paid it all. void choosePayer(String memberId) => _change((form) => form.copyWith(payers: {memberId})); @@ -408,9 +539,13 @@ class EntryForm extends _$EntryForm { ); void setPaidAmount(String memberId, String text) => _change( - (form) => form.copyWith(paidAmounts: {...form.paidAmounts, memberId: text}), + (form) => + form.copyWith(paidAmounts: _override(form.paidAmounts, memberId, text)), ); + /// Releases all manual payer amounts in this draft. + void resetPaid() => _change((form) => form.copyWith(paidAmounts: {})); + /// Saves the expense, and says whether it was saved. When it was not, the /// state says why. Future save() async { @@ -418,27 +553,26 @@ class EntryForm extends _$EntryForm { final form = _form; final currency = ref.read(currenciesProvider).value?[form.currencyCode]; if (ledger == null || currency == null || form.saving) return false; - - final fx = await _quote(form, ledger.group.defaultCurrency); - if (!ref.mounted) return false; - final check = form.check( - currency, - groupId: groupId, - fx: fx, - deviceZone: ref.read(deviceZoneProvider).value, - ); - final EntryDraft draft; - switch (check) { - case DraftIncomplete(:final reason): - state = AsyncData(form.copyWith(error: reason)); - return false; - case DraftReady(draft: final ready): - draft = ready; - } - state = AsyncData(form.copyWith(saving: true, error: null)); - final repository = ref.read(entryRepositoryProvider); try { + final fx = await _quote(form, ledger.group.defaultCurrency); + if (!ref.mounted) return false; + final check = form.check( + currency, + groupId: groupId, + fx: fx, + deviceZone: ref.read(deviceZoneProvider).value, + ); + final EntryDraft draft; + switch (check) { + case DraftIncomplete(:final reason): + state = AsyncData(form.copyWith(error: reason)); + return false; + case DraftReady(draft: final ready): + draft = ready; + } + + final repository = ref.read(entryRepositoryProvider); if (form.editing case final editing?) { // Who is editing, which need not be who created it: that difference // is most of what makes a feed line worth reading. @@ -451,6 +585,7 @@ class EntryForm extends _$EntryForm { } else { await repository.create( draft, + id: form.newEntryId, createdBy: ledger.me?.id ?? draft.payerAmounts.keys.first, ); } @@ -469,7 +604,8 @@ class EntryForm extends _$EntryForm { /// Deletes the expense being edited, and says whether it was deleted. Future delete() async { final editing = _form.editing; - if (editing == null) return false; + if (editing == null || _form.saving) return false; + state = AsyncData(_form.copyWith(saving: true, error: null)); try { await ref .read(entryRepositoryProvider) diff --git a/lib/application/settlement_form.dart b/lib/application/settlement_form.dart index 741a07c..d44d251 100644 --- a/lib/application/settlement_form.dart +++ b/lib/application/settlement_form.dart @@ -10,6 +10,9 @@ import '../data/local/database.dart'; import '../domain/calendar_date.dart'; import '../domain/entry_draft.dart'; import '../domain/money_format.dart'; +import '../domain/models/entry.dart'; +import '../domain/split/splitter.dart'; +import 'package:opensplit_api/opensplit_api.dart' show EntryKind; import 'ledger_providers.dart'; import 'local_providers.dart'; @@ -27,6 +30,12 @@ abstract class SettlementFormState with _$SettlementFormState { String? toId, required String currencyCode, @Default('') String amount, + Entry? editing, + DateTime? day, + DateTime? occurredAt, + String? timeZone, + @Default(true) bool includeTime, + @Default('') String notes, /// A UPI ID typed over the payee's own. It is only ever read back for /// the payee it was typed for, so choosing somebody else can never hand @@ -38,6 +47,27 @@ abstract class SettlementFormState with _$SettlementFormState { const SettlementFormState._(); + /// A recorded payment, retaining its date, optional time and note. + factory SettlementFormState.of(Entry entry, Currency? currency) { + if (entry.row.kind != EntryKind.settlement) { + throw StateError('The payment editor needs a settlement.'); + } + return SettlementFormState( + editing: entry, + fromId: entry.payers.single.memberId, + toId: entry.shares.single.memberId, + currencyCode: entry.row.currency, + amount: currency?.formatPlain(entry.row.amountMinor) ?? '', + day: entry.row.entryDate, + occurredAt: entry.row.occurredAt, + timeZone: entry.row.timeZone, + includeTime: entry.row.occurredAt != null, + notes: entry.row.notes ?? '', + ); + } + + bool get isEditing => editing != null; + /// Where the money goes: whatever was typed for this payee, or else the /// handle the group knows them by. String? handleIn(GroupLedger ledger) { @@ -82,6 +112,9 @@ abstract class SettlementFormState with _$SettlementFormState { if (amountMinor <= 0) { return const DraftIncomplete('Enter an amount greater than zero.'); } + final effectiveZone = timeZone ?? zone; + final moment = occurredAt ?? at.toUtc(); + final timed = includeTime && effectiveZone != null; return DraftReady( EntryDraft.settlement( groupId: groupId, @@ -89,10 +122,13 @@ abstract class SettlementFormState with _$SettlementFormState { amountMinor: amountMinor, fromMemberId: from, toMemberId: to, - entryDate: calendarDay(at), + entryDate: day ?? calendarDay(at), // With no zone known yet, only the day is kept. - occurredAt: zone == null ? null : at.toUtc(), - timeZone: zone, + occurredAt: timed ? moment : null, + timeZone: timed ? effectiveZone : null, + notes: notes.trim().isEmpty ? null : notes.trim(), + // Keep older clients' descriptions; the UI derives the payment title. + description: editing?.row.description ?? '', ), ); } @@ -105,39 +141,52 @@ abstract class SettlementFormState with _$SettlementFormState { @riverpod class SettlementForm extends _$SettlementForm { @override - Future build( + FutureOr build( String groupId, { String? fromId, String? toId, int? amountMinor, String? currency, + String? entryId, }) { final seeded = Completer(); + SettlementFormState? ready; void seed() { if (seeded.isCompleted) return; final ledger = ref.read(groupLedgerProvider(groupId)); final currencies = ref.read(currenciesProvider).value; if (ledger == null || currencies == null) return; + if (entryId != null) { + final entry = ledger.entries.where((e) => e.id == entryId).firstOrNull; + if (entry == null) return; + ready = SettlementFormState.of(entry, currencies[entry.row.currency]); + seeded.complete(ready); + return; + } + final from = fromId ?? ledger.me?.id; final code = currency ?? ledger.group.defaultCurrency; final amount = amountMinor; - seeded.complete( - SettlementFormState( - fromId: from, - toId: toId == from ? null : toId, - currencyCode: code, - amount: amount == null - ? '' - : currencies[code]?.formatPlain(amount) ?? '', - ), + final now = DateTime.now(); + ready = SettlementFormState( + fromId: from, + toId: toId == from ? null : toId, + currencyCode: code, + amount: amount == null + ? '' + : currencies[code]?.formatPlain(amount) ?? '', + day: calendarDay(now), + occurredAt: now.toUtc(), ); + seeded.complete(ready); } ref.listen(groupLedgerProvider(groupId), (_, _) => seed()); ref.listen(currenciesProvider, (_, _) => seed()); seed(); - return seeded.future; + // Preserve async loading for deep links whose local data is still arriving. + return ready ?? seeded.future; } SettlementFormState get _form => state.requireValue; @@ -161,6 +210,40 @@ class SettlementForm extends _$SettlementForm { void setAmount(String text) => _change((form) => form.copyWith(amount: text)); + /// Keeps optional payment context separate from its generated title. + void setNotes(String text) => _change((form) => form.copyWith(notes: text)); + + /// Changing the day clears a time whose date is no longer known. + void chooseDay(DateTime day) => _change((form) { + final picked = calendarDay(day); + if (picked == form.day) return form; + return form.copyWith( + day: picked, + occurredAt: null, + timeZone: null, + includeTime: false, + ); + }); + + /// A time chosen on this device uses its clock and zone. + void chooseTime({ + required int hour, + required int minute, + required String zone, + }) => _change((form) { + final day = form.day ?? calendarDay(DateTime.now()); + return form.copyWith( + occurredAt: DateTime(day.year, day.month, day.day, hour, minute).toUtc(), + timeZone: zone, + includeTime: true, + ); + }); + + void clearTime() => _change( + (form) => + form.copyWith(occurredAt: null, timeZone: null, includeTime: false), + ); + /// A UPI ID typed for whoever is being paid now. void setHandle(String text) => _change((form) { final to = form.toId; @@ -194,19 +277,55 @@ class SettlementForm extends _$SettlementForm { } state = AsyncData(form.copyWith(saving: true, error: null)); + try { + final repository = ref.read(entryRepositoryProvider); + if (form.editing case final editing?) { + await repository.update( + editing.id, + draft, + actorId: ledger.me?.id, + expected: editing, + ); + } else { + await repository.create( + draft, + createdBy: ledger.me?.id ?? draft.payerAmounts.keys.first, + ); + } + return true; + } catch (error) { + _fail(error); + return false; + } + } + + /// Removes the recorded payment without erasing its history. + Future delete() async { + final editing = _form.editing; + if (editing == null || _form.saving) return false; + state = AsyncData(_form.copyWith(saving: true, error: null)); try { await ref .read(entryRepositoryProvider) - .create( - draft, - createdBy: ledger.me?.id ?? draft.payerAmounts.keys.first, + .delete( + editing.id, + actorId: ref.read(groupLedgerProvider(groupId))?.me?.id, + expected: editing, ); return true; } catch (error) { - if (ref.mounted) { - state = AsyncData(_form.copyWith(saving: false, error: '$error')); - } + _fail(error); return false; } } + + void _fail(Object error) { + if (!ref.mounted) return; + state = AsyncData( + _form.copyWith( + saving: false, + error: error is SplitException ? error.message : '$error', + ), + ); + } } diff --git a/lib/data/repositories/drift_entry_repository.dart b/lib/data/repositories/drift_entry_repository.dart index 6cd29f2..28d9a53 100644 --- a/lib/data/repositories/drift_entry_repository.dart +++ b/lib/data/repositories/drift_entry_repository.dart @@ -6,6 +6,7 @@ import '../../domain/activity/snapshot_diff.dart'; import '../../domain/entry_draft.dart'; import '../../domain/models/entry.dart'; import '../../domain/models/entry_snapshot.dart'; +import '../../domain/split/splitter.dart'; import '../local/database.dart'; import '../local/entry_writer.dart'; import '../local/tables.dart'; @@ -180,9 +181,13 @@ final class DriftEntryRepository { } /// Resolves [draft] into a balanced entry and stores it. + /// + /// A locally generated [id] keeps preview rounding consistent with the write. + /// An existing identity is refused rather than used as an implicit edit. Future create( EntryDraft draft, { required String createdBy, + String? id, DateTime? now, }) async { // Composed before the transaction opens: if the split does not resolve, no @@ -190,12 +195,17 @@ final class DriftEntryRepository { final at = now ?? _clock(); final entry = composeEntry( draft, - id: _uuid.v4(), + id: id ?? _uuid.v4(), createdBy: createdBy, now: at, ); await _db.transaction(() async { + if (await getEntry(entry.id) != null) { + throw const SplitException( + 'This expense is already recorded. Reopen it to make changes.', + ); + } await _unarchive(entry.row.groupId); await _writeWithSnapshot(after: entry, actorId: createdBy, at: at); }); @@ -223,6 +233,11 @@ final class DriftEntryRepository { throw StateError('Entry $entryId does not exist'); } _checkExpected(existing, expected); + if (existing.row.kind != draft.kind) { + throw const SplitException( + 'An expense cannot become a payment, or a payment an expense.', + ); + } final at = now ?? _clock(); // Recomposed rather than patched, so an edit goes through exactly the same diff --git a/lib/domain/avatar.dart b/lib/domain/avatar.dart index 9cefa5a..412d9f3 100644 --- a/lib/domain/avatar.dart +++ b/lib/domain/avatar.dart @@ -6,8 +6,8 @@ import 'package:opensplit_api/opensplit_api.dart' /// What stands for a person or a group wherever there is no room for a name. /// /// One of four kinds, each carrying only what it shows. [color] is the hue -/// behind initials, an emoji or an icon; null until somebody picks one, and -/// then [hueFor] derives one from the owner's id so it never moves by itself. +/// behind initials, an emoji or an icon; null uses the current theme's accent +/// containers, with the owner's id keeping the choice stable across devices. @immutable sealed class Avatar { const Avatar({this.color}); @@ -121,7 +121,7 @@ final List avatarHues = [ if (color != AvatarColor.unknownDefaultOpenApi) color, ]; -/// The hue an avatar has before anybody picks one. +/// A stable hue from the full palette, independent of an app theme. AvatarColor hueFor(String id) => avatarHues[stableHash(id) % avatarHues.length]; /// A hash of [text] that is the same on every device and every run, which diff --git a/lib/domain/entry_draft.dart b/lib/domain/entry_draft.dart index b2178cb..a34754a 100644 --- a/lib/domain/entry_draft.dart +++ b/lib/domain/entry_draft.dart @@ -37,6 +37,7 @@ class EntryDraft { DateTime? occurredAt, String? timeZone, String? notes, + String description = '', }) { if (fromMemberId == toMemberId) { throw const SplitException('A settlement needs two different people.'); @@ -46,6 +47,7 @@ class EntryDraft { currency: currency, amountMinor: amountMinor, kind: EntryKind.settlement, + description: description, split: ExactSplit({toMemberId: amountMinor}), payerAmounts: {fromMemberId: amountMinor}, entryDate: entryDate, @@ -116,6 +118,15 @@ Entry composeEntry( amountsByMemberId: draft.payerAmounts, ); + if (draft.kind == EntryKind.settlement && + (payers.length != 1 || + shares.length != 1 || + payers.single.memberId == shares.single.memberId)) { + throw const SplitException( + 'A payment needs one sender and one different recipient.', + ); + } + return Entry( EntryRow( id: id, diff --git a/lib/domain/split/remainder.dart b/lib/domain/split/remainder.dart new file mode 100644 index 0000000..7cd56f2 --- /dev/null +++ b/lib/domain/split/remainder.dart @@ -0,0 +1,92 @@ +import 'allocation.dart'; + +/// Why fixed values cannot yet form a complete allocation. +enum RemainderProblem { + noMembers, + invalidOverrides, + overallocated, + unallocated, +} + +/// Fixed values plus an equal allocation of the remainder to automatic rows. +/// +/// Presence in [overrides] means fixed; absence means automatic. Values are +/// integer units, so money and percentages can use the same calculation. +/// Invalid drafts keep valid fixed values, but never invent negative shares +/// or silently change an override to make the total balance. +final class RemainderAllocation { + factory RemainderAllocation({ + required int total, + required Set memberIds, + required Map overrides, + String? seed, + }) { + if (total < 0) throw ArgumentError('A total must be non-negative.'); + final automatic = memberIds.difference(overrides.keys.toSet()); + final values = {}; + final invalid = {}; + for (final id in memberIds.intersection(overrides.keys.toSet())) { + final value = overrides[id]; + if (value == null || value < 0) { + invalid.add(id); + } else { + values[id] = value; + } + } + final remaining = + (BigInt.from(total) - + values.values.fold( + BigInt.zero, + (sum, value) => sum + BigInt.from(value), + )) + .toInt(); + final problem = switch (true) { + _ when memberIds.isEmpty => RemainderProblem.noMembers, + _ when invalid.isNotEmpty => RemainderProblem.invalidOverrides, + _ when remaining < 0 => RemainderProblem.overallocated, + _ when automatic.isEmpty && remaining > 0 => RemainderProblem.unallocated, + _ => null, + }; + if (problem == null && automatic.isNotEmpty) { + final allocated = allocateLargestRemainder( + totalMinor: remaining, + parties: [for (final id in automatic) (memberId: id, weightMicros: 1)], + seed: seed, + ); + values.addAll(allocated); + } + return RemainderAllocation._( + values: Map.unmodifiable(values), + automatic: Set.unmodifiable(automatic), + invalid: Set.unmodifiable(invalid), + remaining: remaining, + problem: problem, + ); + } + + const RemainderAllocation._({ + required this.values, + required this.automatic, + required this.invalid, + required this.remaining, + required this.problem, + }); + + /// All resolved values when balanced, or the valid overrides when not. + final Map values; + + /// Selected members whose values follow the remainder. + final Set automatic; + + /// Selected members with an unreadable or negative override. + final Set invalid; + + /// Units left after the valid fixed values, before automatic allocation. + final int remaining; + + /// Null when [values] contains a complete, balanced allocation. + final RemainderProblem? problem; + + /// Whether these values are ready to become a split or payer allocation. + bool get isBalanced => problem == null; +} diff --git a/lib/main.dart b/lib/main.dart index 50d56cf..a4bf4bd 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -30,7 +30,7 @@ Future main() async { // The faces ship with their licence, and the app can show it. LicenseRegistry.addLicense(() async* { yield LicenseEntryWithLineBreaks(const [ - 'Instrument Sans', + 'Google Sans Flex', ], await rootBundle.loadString('assets/google_fonts/LICENSE')); }); diff --git a/lib/presentation/app.dart b/lib/presentation/app.dart index d3139e7..933b192 100644 --- a/lib/presentation/app.dart +++ b/lib/presentation/app.dart @@ -27,7 +27,8 @@ class OpenSplitApp extends ConsumerStatefulWidget { ConsumerState createState() => _OpenSplitAppState(); } -class _OpenSplitAppState extends ConsumerState { +class _OpenSplitAppState extends ConsumerState + with WidgetsBindingObserver { /// A messenger above the router, because the update offer outlives whichever /// screen happened to be open when Play answered. final _messengerKey = GlobalKey(); @@ -46,6 +47,7 @@ class _OpenSplitAppState extends ConsumerState { @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); _lifecycle = AppLifecycleListener(onResume: _onResume); // Not in initState directly: Riverpod's scope is inherited state, which is @@ -100,6 +102,9 @@ class _OpenSplitAppState extends ConsumerState { /// Coming back to the foreground asks both questions worth asking: what has /// the group been doing, and is there a new version of the app. void _onResume() { + // One UI can change its palette while the app is in the background. + // Re-read the plugin; brightness and the palette are separate settings. + ref.invalidate(wallpaperSchemesProvider); if (ref.read(signedInProvider)) { unawaited(ref.read(appDatabaseProvider).noticeWritesElsewhere()); } @@ -107,8 +112,14 @@ class _OpenSplitAppState extends ConsumerState { _offerUpdate(); } + @override + void didChangePlatformBrightness() { + ref.invalidate(wallpaperSchemesProvider); + } + @override void dispose() { + WidgetsBinding.instance.removeObserver(this); _lifecycle.dispose(); super.dispose(); } @@ -193,6 +204,12 @@ class _OpenSplitAppState extends ConsumerState { // Defaults to following the platform — see [ThemeModeController]. themeMode: ref.watch(themeModeProvider), routerConfig: ref.watch(routerProvider), + // Order route scopes without measuring their transitional geometry. + // Each screen retains standard visual reading order within its scope. + builder: (context, child) => FocusTraversalGroup( + policy: WidgetOrderTraversalPolicy(), + child: child!, + ), ); } } diff --git a/lib/presentation/dynamic_colors.dart b/lib/presentation/dynamic_colors.dart index fee96e8..2d9ef2d 100644 --- a/lib/presentation/dynamic_colors.dart +++ b/lib/presentation/dynamic_colors.dart @@ -1,3 +1,5 @@ +import 'dart:developer' as developer; + import 'package:dynamic_color/dynamic_color.dart'; import 'package:material_ui/material_ui.dart'; import 'package:flutter/services.dart'; @@ -18,7 +20,18 @@ final wallpaperSchemesProvider = FutureProvider((ref) async { dark: palette.toColorScheme(brightness: Brightness.dark), ); } + } on PlatformException catch (error, stackTrace) { + developer.log( + 'Could not read the wallpaper palette', + name: 'opensplit.appearance', + error: error, + stackTrace: stackTrace, + ); + } + // The accent is a separate platform capability. A failure to read the + // Android palette must not prevent a desktop accent from being used. + try { final accent = await DynamicColorPlugin.getAccentColor(); if (accent != null) { return ( @@ -29,9 +42,13 @@ final wallpaperSchemesProvider = FutureProvider((ref) async { ), ); } - } on PlatformException { - // A platform that has the plugin but refused to answer. Indistinguishable - // from not having one, as far as this app is concerned. + } on PlatformException catch (error, stackTrace) { + developer.log( + 'Could not read the system accent', + name: 'opensplit.appearance', + error: error, + stackTrace: stackTrace, + ); } return null; }); diff --git a/lib/presentation/router.dart b/lib/presentation/router.dart index cd78cdb..d563e2d 100644 --- a/lib/presentation/router.dart +++ b/lib/presentation/router.dart @@ -73,8 +73,9 @@ GoRouter buildRouter({ }) => GoRouter( initialLocation: '/', refreshListenable: refresh, - errorBuilder: (context, state) => - NotFoundScreen(location: state.uri.toString()), + errorBuilder: (context, state) => _ReadingOrderScreen( + child: NotFoundScreen(location: state.uri.toString()), + ), /// Nothing above the welcome screen works without a session. redirect: (context, state) => @@ -90,95 +91,120 @@ GoRouter buildRouter({ // No transition, in both directions. GoRoute( path: '/welcome', - pageBuilder: (context, state) => - const NoTransitionPage(child: WelcomeScreen()), + pageBuilder: (context, state) => const NoTransitionPage( + child: _ReadingOrderScreen(child: WelcomeScreen()), + ), ), // The link a friend sends. GoRoute( path: '/join/:token', - builder: (context, state) => - JoinScreen(token: state.pathParameters['token']!), + builder: (context, state) => _ReadingOrderScreen( + child: JoinScreen(token: state.pathParameters['token']!), + ), ), GoRoute( path: '/archived', - builder: (context, state) => const ArchivedGroupsScreen(), + builder: (context, state) => + const _ReadingOrderScreen(child: ArchivedGroupsScreen()), ), // Above the shell rather than inside Settings' branch, so it arrives // with a back arrow instead of a menu button -- it is a screen reached // from a destination, not a destination. - GoRoute(path: '/about', builder: (context, state) => const AboutScreen()), + GoRoute( + path: '/about', + builder: (context, state) => + const _ReadingOrderScreen(child: AboutScreen()), + ), // Reached from the Account destination, above the shell for the same // reason as About. GoRoute( path: '/account/edit', - builder: (context, state) => EditProfileScreen( - focus: - ProfileField.values - .asNameMap()[state.uri.queryParameters['field']] ?? - ProfileField.name, + builder: (context, state) => _ReadingOrderScreen( + child: EditProfileScreen( + focus: + ProfileField.values + .asNameMap()[state.uri.queryParameters['field']] ?? + ProfileField.name, + ), ), ), GoRoute( path: '/account/save', - builder: (context, state) => const SaveAccountScreen(), + builder: (context, state) => + const _ReadingOrderScreen(child: SaveAccountScreen()), ), GoRoute( path: '/account/picture', - builder: (context, state) => const AvatarPickerScreen.profile(), + builder: (context, state) => + const _ReadingOrderScreen(child: AvatarPickerScreen.profile()), ), GoRoute( path: '/g/:groupId', - builder: (context, state) => - GroupDetailScreen(groupId: state.pathParameters['groupId']!), + builder: (context, state) => _ReadingOrderScreen( + child: GroupDetailScreen(groupId: state.pathParameters['groupId']!), + ), routes: [ GoRoute( path: 'add', - builder: (context, state) => - EntryEditorScreen(groupId: state.pathParameters['groupId']!), + builder: (context, state) => _ReadingOrderScreen( + child: EntryEditorScreen(groupId: state.pathParameters['groupId']!), + ), ), GoRoute( path: 'activity', - builder: (context, state) => - ActivityScreen(groupId: state.pathParameters['groupId']!), + builder: (context, state) => _ReadingOrderScreen( + child: ActivityScreen(groupId: state.pathParameters['groupId']!), + ), ), GoRoute( path: 'insights', - builder: (context, state) => - InsightsScreen(groupId: state.pathParameters['groupId']!), + builder: (context, state) => _ReadingOrderScreen( + child: InsightsScreen(groupId: state.pathParameters['groupId']!), + ), ), GoRoute( path: 'settings', - builder: (context, state) => - GroupSettingsScreen(groupId: state.pathParameters['groupId']!), + builder: (context, state) => _ReadingOrderScreen( + child: GroupSettingsScreen( + groupId: state.pathParameters['groupId']!, + ), + ), ), GoRoute( path: 'members', - builder: (context, state) => - MembersScreen(groupId: state.pathParameters['groupId']!), + builder: (context, state) => _ReadingOrderScreen( + child: MembersScreen(groupId: state.pathParameters['groupId']!), + ), ), GoRoute( path: 'picture', - builder: (context, state) => AvatarPickerScreen.group( - groupId: state.pathParameters['groupId']!, + builder: (context, state) => _ReadingOrderScreen( + child: AvatarPickerScreen.group( + groupId: state.pathParameters['groupId']!, + ), ), ), GoRoute( path: 'settle', - builder: (context, state) => SettleUpScreen( - groupId: state.pathParameters['groupId']!, - fromMemberId: state.uri.queryParameters['from'], - toMemberId: state.uri.queryParameters['to'], - amountMinor: int.tryParse( - state.uri.queryParameters['amount'] ?? '', + builder: (context, state) => _ReadingOrderScreen( + child: SettleUpScreen( + groupId: state.pathParameters['groupId']!, + fromMemberId: state.uri.queryParameters['from'], + toMemberId: state.uri.queryParameters['to'], + amountMinor: int.tryParse( + state.uri.queryParameters['amount'] ?? '', + ), + currency: state.uri.queryParameters['currency'], ), - currency: state.uri.queryParameters['currency'], ), ), GoRoute( path: 'e/:entryId', - builder: (context, state) => EntryEditorScreen( - groupId: state.pathParameters['groupId']!, - entryId: state.pathParameters['entryId'], + builder: (context, state) => _ReadingOrderScreen( + child: EntryEditorScreen( + groupId: state.pathParameters['groupId']!, + entryId: state.pathParameters['entryId'], + ), ), ), ], @@ -197,7 +223,8 @@ GoRouter buildRouter({ routes: [ GoRoute( path: '/', - builder: (context, state) => const GroupListScreen(), + builder: (context, state) => + const _ReadingOrderScreen(child: GroupListScreen()), ), ], ), @@ -206,7 +233,8 @@ GoRouter buildRouter({ routes: [ GoRoute( path: '/account', - builder: (context, state) => const AccountScreen(), + builder: (context, state) => + const _ReadingOrderScreen(child: AccountScreen()), ), ], ), @@ -214,7 +242,8 @@ GoRouter buildRouter({ routes: [ GoRoute( path: '/settings', - builder: (context, state) => const SettingsScreen(), + builder: (context, state) => + const _ReadingOrderScreen(child: SettingsScreen()), ), ], ), @@ -223,6 +252,18 @@ GoRouter buildRouter({ ], ); +/// Keeps each screen's Tab sequence in visual reading order. The outer app +/// group orders route scopes without measuring their transitional geometry. +class _ReadingOrderScreen extends StatelessWidget { + const _ReadingOrderScreen({required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) => + FocusTraversalGroup(policy: ReadingOrderTraversalPolicy(), child: child); +} + /// Shows the selected destination, fading through from the last one. /// /// Material's pattern for moving between top-level destinations, which are not @@ -306,20 +347,25 @@ class _Branch extends StatelessWidget { final Widget child; @override - Widget build(BuildContext context) => Offstage( - offstage: !selected && !leaving, - child: TickerMode( - enabled: selected, - child: IgnorePointer( - ignoring: !selected, - child: ExcludeSemantics( - excluding: !selected, - child: FadeThroughTransition( - animation: selected ? fade : kAlwaysCompleteAnimation, - secondaryAnimation: leaving ? fade : kAlwaysDismissedAnimation, - // The one fill is painted behind every branch, by the parent. - fillColor: Colors.transparent, - child: child, + Widget build(BuildContext context) => HeroMode( + // The root navigator can collect heroes from nested navigators. Only the + // active destination should participate in a pushed page's transition. + enabled: selected, + child: Offstage( + offstage: !selected && !leaving, + child: TickerMode( + enabled: selected, + child: IgnorePointer( + ignoring: !selected, + child: ExcludeSemantics( + excluding: !selected, + child: FadeThroughTransition( + animation: selected ? fade : kAlwaysCompleteAnimation, + secondaryAnimation: leaving ? fade : kAlwaysDismissedAnimation, + // The one fill is painted behind every branch, by the parent. + fillColor: Colors.transparent, + child: child, + ), ), ), ), @@ -356,28 +402,30 @@ class AdaptiveNavigation extends StatelessWidget { @override Widget build(BuildContext context) { - // Narrow: nothing here at all. - if (MediaQuery.sizeOf(context).width < _railBreakpoint) return shell; - + final wide = MediaQuery.sizeOf(context).width >= _railBreakpoint; final l10n = AppLocalizations.of(context); + // Keep the navigator's ancestry stable across the drawer/rail breakpoint. + // Reparenting it while a route restores keyboard focus can leave traversal + // trying to measure a render object that has just been detached. return Scaffold( body: Row( children: [ - NavigationRail( - selectedIndex: shell.currentIndex, - labelType: NavigationRailLabelType.all, - minWidth: _railWidth, - onDestinationSelected: _select, - destinations: [ - for (final destination in _destinations) - NavigationRailDestination( - icon: Icon(destination.icon), - selectedIcon: Icon(destination.selectedIcon), - label: Text(destination.label(l10n)), - ), - ], - ), - const VerticalDivider(width: 1), + if (wide) + NavigationRail( + selectedIndex: shell.currentIndex, + labelType: NavigationRailLabelType.all, + minWidth: _railWidth, + onDestinationSelected: _select, + destinations: [ + for (final destination in _destinations) + NavigationRailDestination( + icon: Icon(destination.icon), + selectedIcon: Icon(destination.selectedIcon), + label: Text(destination.label(l10n)), + ), + ], + ), + if (wide) const VerticalDivider(width: 1), Expanded(child: shell), ], ), diff --git a/lib/presentation/screens/avatar_picker_screen.dart b/lib/presentation/screens/avatar_picker_screen.dart index 244fda9..2b4a974 100644 --- a/lib/presentation/screens/avatar_picker_screen.dart +++ b/lib/presentation/screens/avatar_picker_screen.dart @@ -9,6 +9,8 @@ import '../../domain/avatar.dart'; import '../navigation.dart'; import '../theme.dart'; import '../widgets/avatar_view.dart'; +import '../widgets/group_avatar_hero.dart'; +import '../widgets/shared_element.dart'; import '../widgets/page_body.dart'; import '../widgets/sync_status_notice.dart'; @@ -31,7 +33,7 @@ class AvatarPickerScreen extends ConsumerStatefulWidget { } /// What the picker needs to know about whoever it is picturing. -typedef _Subject = ({Avatar avatar, String name, String id}); +typedef _Subject = ({Avatar avatar, String name, String id, Group? group}); class _AvatarPickerScreenState extends ConsumerState { Avatar? _original; @@ -47,7 +49,12 @@ class _AvatarPickerScreenState extends ConsumerState { final group = ref.watch(groupProvider(groupId)).value; return group == null ? null - : (avatar: group.avatar, name: group.name, id: group.id); + : ( + avatar: group.avatar, + name: group.name, + id: group.id, + group: group, + ); } final id = ref.watch(currentAccountIdProvider); if (id == null) return null; @@ -60,6 +67,7 @@ class _AvatarPickerScreenState extends ConsumerState { avatar: profile?.avatar ?? const InitialsAvatar(), name: profile?.displayName ?? '', id: id, + group: null, ); } @@ -152,25 +160,43 @@ class _Picker extends StatelessWidget { @override Widget build(BuildContext context) { final textTheme = Theme.of(context).textTheme; - final hue = chosen.color ?? hueFor(subject.id); + final palette = AvatarPalette.of(context); return ListView( padding: const EdgeInsets.fromLTRB(16, 24, 16, 32), children: [ Center( - child: AvatarView( - avatar: chosen, - name: subject.name, - id: subject.id, - radius: 56, - ), + child: switch (subject.group) { + final group? => GroupAvatarHero( + group: group, + radius: 56, + avatar: chosen, + ), + null => AvatarView( + avatar: chosen, + name: subject.name, + id: subject.id, + radius: 56, + ), + }, ), const SizedBox(height: 12), - Text( - subject.name.isEmpty ? 'No name yet' : subject.name, - textAlign: TextAlign.center, - style: textTheme.titleMedium, - ), + if (subject.group case final group?) + Center( + child: SharedText( + tag: ('group-name', group.id), + text: group.name, + style: textTheme.titleMedium!.copyWith( + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ) + else + Text( + subject.name.isEmpty ? 'No name yet' : subject.name, + textAlign: TextAlign.center, + style: textTheme.titleMedium, + ), const SizedBox(height: 24), Center( child: SegmentedButton<_Kind>( @@ -198,7 +224,11 @@ class _Picker extends StatelessWidget { const SizedBox(height: 24), _Heading('Colour'), _Hues( - selected: hue, + selected: chosen.color, + automaticScheme: palette.forAvatar( + chosen.withColor(null), + subject.id, + ), onSelected: (hue) => onChanged(chosen.withColor(hue)), ), ...switch (chosen) { @@ -255,10 +285,15 @@ class _Heading extends StatelessWidget { /// One filled button per hue, the hue itself as its colour. class _Hues extends StatelessWidget { - const _Hues({required this.selected, required this.onSelected}); + const _Hues({ + required this.selected, + required this.automaticScheme, + required this.onSelected, + }); - final AvatarColor selected; - final ValueChanged onSelected; + final AvatarColor? selected; + final ColorScheme automaticScheme; + final ValueChanged onSelected; @override Widget build(BuildContext context) { @@ -267,6 +302,17 @@ class _Hues extends StatelessWidget { spacing: 8, runSpacing: 8, children: [ + IconButton.filled( + tooltip: 'Automatic', + isSelected: selected == null, + onPressed: () => onSelected(null), + style: IconButton.styleFrom( + backgroundColor: automaticScheme.primaryContainer, + foregroundColor: automaticScheme.onPrimaryContainer, + ), + icon: const Icon(Icons.palette_outlined), + selectedIcon: const Icon(Icons.check), + ), for (final hue in avatarHues) IconButton.filled( tooltip: _hueName(hue), diff --git a/lib/presentation/screens/entry_editor_screen.dart b/lib/presentation/screens/entry_editor_screen.dart index c60a236..0a57f85 100644 --- a/lib/presentation/screens/entry_editor_screen.dart +++ b/lib/presentation/screens/entry_editor_screen.dart @@ -1,24 +1,26 @@ import 'package:material_ui/material_ui.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:intl/intl.dart'; -import 'package:opensplit_api/opensplit_api.dart' show SplitKind; +import 'package:opensplit_api/opensplit_api.dart' show EntryKind, SplitKind; import '../../application/entry_form.dart'; import '../../application/ledger_providers.dart'; import '../../application/local_providers.dart'; -import '../../application/sync_providers.dart'; import '../../data/local/database.dart'; -import '../../domain/activity/activity_text.dart'; import '../../domain/decimal_text.dart'; -import '../../domain/models/group_event.dart'; import '../../domain/money_format.dart'; import '../amount_input.dart'; import '../feedback.dart'; import '../navigation.dart'; import '../theme.dart'; +import '../widgets/amount_card.dart'; +import '../widgets/entry_history.dart'; import '../widgets/avatar_view.dart'; import '../widgets/category_icon.dart'; +import '../widgets/shared_element.dart'; import '../widgets/page_body.dart'; +import '../widgets/remainder_field.dart'; +import 'settle_up_screen.dart'; /// Creates or edits an expense. /// @@ -35,7 +37,20 @@ class EntryEditorScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final ledger = ref.watch(groupLedgerProvider(groupId)); - final form = ref.watch(entryFormProvider(groupId, entryId: entryId)).value; + final entry = ledger?.entries.where((e) => e.id == entryId).firstOrNull; + if (entry?.row.kind == EntryKind.settlement) { + return SettleUpScreen(groupId: groupId, entryId: entryId); + } + final provider = entryFormProvider(groupId, entryId: entryId); + ref.listen(provider, (previous, next) { + final error = next.value?.error; + if (error != null && error != previous?.value?.error) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(error))); + } + }); + final form = ref.watch(provider).value; if (ledger == null || form == null) { return Scaffold(appBar: AppBar(leading: const BackButton())); @@ -43,9 +58,7 @@ class EntryEditorScreen extends ConsumerWidget { return _Editor( ledger: ledger, form: form, - controller: ref.read( - entryFormProvider(groupId, entryId: entryId).notifier, - ), + controller: ref.read(provider.notifier), ); } } @@ -142,7 +155,7 @@ class _Editor extends ConsumerWidget { if (form.isEditing) IconButton( tooltip: 'Delete', - onPressed: () => _delete(context), + onPressed: ready ? () => _delete(context) : null, icon: const Icon(Icons.delete_outline), ), Padding( @@ -154,125 +167,140 @@ class _Editor extends ConsumerWidget { ), ], ), - body: PageBody( - child: ListView( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 48), - children: [ - // The amount is what the whole screen is for, so it is the - // largest thing on it. - Row( - crossAxisAlignment: CrossAxisAlignment.center, + body: ExcludeFocus( + excluding: form.saving, + child: AbsorbPointer( + absorbing: form.saving, + child: PageBody( + maxWidth: 640, + child: ListView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 48), children: [ - _CurrencyButton( - code: form.currencyCode, - onChanged: controller.chooseCurrency, + AmountCard( + heroTag: form.editing == null + ? null + : ('entry-amount', form.editing!.id), + currency: currency, + currencyCode: form.currencyCode, + amount: form.amount, + autofocus: !form.isEditing, + onAmountChanged: controller.setAmount, + onCurrencyChanged: controller.chooseCurrency, ), - const SizedBox(width: 16), - Expanded( - child: TextFormField( - initialValue: form.amount, - autofocus: !form.isEditing, - keyboardType: const TextInputType.numberWithOptions( - decimal: true, + if (currency != null && + currency.code != ledger.group.defaultCurrency) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + 'Stored in ${currency.code}. Balances in a group are ' + 'always kept per currency, never converted.', + style: textTheme.bodySmall, ), - inputFormatters: [DecimalInputFormatter.amount(currency)], - textInputAction: TextInputAction.next, - style: moneyStyle(textTheme.displaySmall!), - decoration: InputDecoration( - labelText: 'How much?', - hintText: currency == null - ? '0' - : currency.formatPlain(0), - prefixText: currency?.symbol == null - ? null - : '${currency!.symbol} ', - border: InputBorder.none, + ), + const SizedBox(height: 16), + SharedText( + tag: ( + 'entry-description', + form.editing?.id ?? ('draft', ledger.group.id), + ), + text: form.description, + style: textTheme.bodyLarge!.copyWith( + color: Theme.of(context).colorScheme.onSurface, + ), + child: TextFormField( + initialValue: form.description, + textCapitalization: TextCapitalization.sentences, + decoration: const InputDecoration( + labelText: 'What was it?', + hintText: 'Dinner at Toit', ), - onChanged: controller.setAmount, + onChanged: controller.setDescription, ), ), - ], - ), - if (currency != null && - currency.code != ledger.group.defaultCurrency) - Padding( - padding: const EdgeInsets.only(top: 4), - child: Text( - 'Stored in ${currency.code}. Balances in a group are ' - 'always kept per currency, never converted.', - style: textTheme.bodySmall, + const SizedBox(height: 16), + // The details most expenses leave as they are, as chips rather + // than a field each. + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _CategoryChip( + heroTag: form.editing == null + ? null + : ('entry-category', form.editing!.id), + value: form.categoryId, + onChanged: controller.chooseCategory, + ), + ActionChip( + avatar: const Icon(Icons.event_outlined), + label: Text(_dayName(form.day)), + tooltip: 'Change the day', + onPressed: () => _pickDay(context), + ), + ], ), - ), - const SizedBox(height: 16), - TextFormField( - initialValue: form.description, - textCapitalization: TextCapitalization.sentences, - decoration: const InputDecoration( - labelText: 'What was it?', - hintText: 'Dinner at Toit', - ), - onChanged: controller.setDescription, - ), - const SizedBox(height: 16), - // The details most expenses leave as they are, as chips rather - // than a field each. - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - _CategoryChip( - value: form.categoryId, - onChanged: controller.chooseCategory, + const SizedBox(height: 24), + _EditorCard( + child: _PayerSection( + ledger: ledger, + currency: currency, + form: form, + controller: controller, + ), ), - ActionChip( - avatar: const Icon(Icons.event_outlined), - label: Text(_dayName(form.day)), - tooltip: 'Change the day', - onPressed: () => _pickDay(context), + const SizedBox(height: 16), + _EditorCard( + child: _SplitSection( + ledger: ledger, + currency: currency, + form: form, + controller: controller, + ), ), - _TimeChip( - shown: form.occurredAt?.toLocal(), - onPick: device == null - ? null - : () => _pickTime(context, device), - onClear: controller.clearTime, + const SizedBox(height: 16), + ExpansionTile( + title: const Text('Time of expense'), + subtitle: Text( + form.occurredAt == null + ? 'Date only' + : DateFormat.jm().format(form.occurredAt!.toLocal()), + ), + leading: const Icon(Icons.schedule_outlined), + tilePadding: const EdgeInsets.symmetric(horizontal: 16), + childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + expandedCrossAxisAlignment: CrossAxisAlignment.start, + children: [ + _TimeChip( + shown: form.occurredAt?.toLocal(), + onPick: device == null + ? null + : () => _pickTime(context, device), + onClear: controller.clearTime, + ), + ], ), - ], - ), - const SizedBox(height: 32), - _PayerSection( - ledger: ledger, - currency: currency, - form: form, - controller: controller, - ), - const SizedBox(height: 32), - _SplitSection( - ledger: ledger, - currency: currency, - form: form, - controller: controller, - ), - if (form.error case final error?) ...[ - const SizedBox(height: 16), - Card.filled( - color: Theme.of(context).colorScheme.errorContainer, - child: Padding( - padding: const EdgeInsets.all(12), - child: Text( - error, - style: textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onErrorContainer, + if (form.error case final error?) ...[ + const SizedBox(height: 16), + Card.filled( + color: Theme.of(context).colorScheme.errorContainer, + child: Padding( + padding: const EdgeInsets.all(12), + child: Text( + error, + style: textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onErrorContainer, + ), + ), ), ), - ), - ), - ], - if (form.editing case final editing?) ...[ - const SizedBox(height: 32), - _History(entryId: editing.id, ledger: ledger), - ], - ], + ], + if (form.editing case final editing?) ...[ + const SizedBox(height: 32), + EntryHistory(entryId: editing.id, ledger: ledger), + ], + ], + ), + ), ), ), ); @@ -290,6 +318,19 @@ class _Editor extends ConsumerWidget { } } +/// Separates a money decision from the expense's supporting details. +class _EditorCard extends StatelessWidget { + const _EditorCard({required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) => Card.filled( + color: Theme.of(context).colorScheme.surfaceContainerLow, + child: Padding(padding: const EdgeInsets.all(16), child: child), + ); +} + /// A section of the editor, named. class _SectionTitle extends StatelessWidget { const _SectionTitle(this.text, {this.action}); @@ -307,12 +348,7 @@ class _SectionTitle extends StatelessWidget { Expanded( child: Semantics( header: true, - child: Text( - text, - style: theme.textTheme.titleSmall?.copyWith( - color: theme.colorScheme.primary, - ), - ), + child: Text(text, style: theme.textTheme.titleMedium), ), ), ?action, @@ -322,6 +358,19 @@ class _SectionTitle extends StatelessWidget { } } +/// Original participants remain editable even if they have since left. +List _membersForEntry(GroupLedger ledger, EntryFormState form) { + final original = { + ...?form.editing?.shares.map((share) => share.memberId), + ...?form.editing?.payers.map((payer) => payer.memberId), + }; + return [ + ...ledger.members, + for (final member in ledger.pastMembers) + if (original.contains(member.id)) member, + ]; +} + class _PayerSection extends StatelessWidget { const _PayerSection({ required this.ledger, @@ -337,6 +386,9 @@ class _PayerSection extends StatelessWidget { @override Widget build(BuildContext context) { + final currency = this.currency; + final allocation = currency == null ? null : form.paidAllocation(currency); + final members = _membersForEntry(ledger, form); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -352,10 +404,8 @@ class _PayerSection extends StatelessWidget { spacing: 8, runSpacing: 8, children: [ - for (final member in ledger.members) + for (final member in members) ChoiceChip( - // The person's own picture says which one is chosen better - // than a tick would. showCheckmark: false, avatar: MemberAvatar( ledger: ledger, @@ -371,56 +421,38 @@ class _PayerSection extends StatelessWidget { ), ], ) - else - Column( - children: [ - for (final member in ledger.members) - Row( - children: [ - Expanded( - child: CheckboxListTile( - contentPadding: EdgeInsets.zero, - controlAffinity: ListTileControlAffinity.leading, - value: form.payers.contains(member.id), - title: Text(ledger.nameOfMember(member)), - onChanged: (checked) => controller.setPaying( - member.id, - paying: checked ?? false, - ), - ), - ), - SizedBox( - width: 110, - child: TextFormField( - key: ValueKey(('paid', member.id)), - initialValue: form.paidAmounts[member.id], - enabled: form.payers.contains(member.id), - keyboardType: const TextInputType.numberWithOptions( - decimal: true, - ), - inputFormatters: [ - DecimalInputFormatter.amount(currency), - ], - decoration: InputDecoration( - isDense: true, - prefixText: currency?.symbol, - ), - onChanged: (text) => - controller.setPaidAmount(member.id, text), - ), + else ...[ + for (final member in members) + _AllocationRow( + title: ledger.nameOfMember(member), + selected: form.payers.contains(member.id), + onChanged: (checked) => + controller.setPaying(member.id, paying: checked), + input: !form.payers.contains(member.id) + ? null + : RemainderField( + key: ValueKey(('paid', member.id)), + label: 'Paid amount', + memberName: ledger.nameOfMember(member), + manualText: form.paidAmounts[member.id], + automaticText: switch (allocation?.values[member.id]) { + final value? => currency?.formatPlain(value), + null => null, + }, + prefixText: currency?.symbol, + invalid: allocation?.invalid.contains(member.id) ?? false, + inputFormatters: [DecimalInputFormatter.amount(currency)], + onChanged: (text) => + controller.setPaidAmount(member.id, text), ), - ], - ), - const SizedBox(height: 4), - Align( - alignment: Alignment.centerLeft, - child: Text( - 'The amounts paid have to add up to the total.', - style: Theme.of(context).textTheme.bodySmall, - ), - ), - ], + ), + _AllocationStatus( + error: currency == null ? null : form.paidError(currency), + hasOverrides: form.payers.any(form.paidAmounts.containsKey), + onReset: controller.resetPaid, + resetLabel: 'Divide evenly', ), + ], ], ); } @@ -443,106 +475,233 @@ class _SplitSection extends StatelessWidget { Widget build(BuildContext context) { final currency = this.currency; final preview = currency == null ? null : form.preview(currency); + final allocation = switch (form.splitKind) { + SplitKind.exact => + currency == null ? null : form.exactAllocation(currency), + SplitKind.percent => form.percentAllocation(), + _ => null, + }; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const _SectionTitle('Split'), - SegmentedButton( - segments: const [ - ButtonSegment(value: SplitKind.equal, label: Text('Equally')), - ButtonSegment(value: SplitKind.exact, label: Text('Amounts')), - ButtonSegment(value: SplitKind.shares, label: Text('Shares')), - ButtonSegment(value: SplitKind.percent, label: Text('%')), - ], - selected: {form.splitKind}, - onSelectionChanged: (set) => controller.chooseSplitKind(set.first), - showSelectedIcon: false, + LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth < 360 || + MediaQuery.textScalerOf(context).scale(16) > 24) { + return DropdownMenu( + key: ValueKey(form.splitKind), + label: const Text('Split method'), + initialSelection: form.splitKind, + expandedInsets: EdgeInsets.zero, + requestFocusOnTap: false, + enableSearch: false, + dropdownMenuEntries: const [ + DropdownMenuEntry(value: SplitKind.equal, label: 'Equally'), + DropdownMenuEntry(value: SplitKind.exact, label: 'Amounts'), + DropdownMenuEntry(value: SplitKind.shares, label: 'Shares'), + DropdownMenuEntry(value: SplitKind.percent, label: '%'), + ], + onSelected: (kind) { + if (kind != null) controller.chooseSplitKind(kind); + }, + ); + } + return SegmentedButton( + segments: const [ + ButtonSegment(value: SplitKind.equal, label: Text('Equally')), + ButtonSegment(value: SplitKind.exact, label: Text('Amounts')), + ButtonSegment(value: SplitKind.shares, label: Text('Shares')), + ButtonSegment(value: SplitKind.percent, label: Text('%')), + ], + selected: {form.splitKind}, + onSelectionChanged: (set) => + controller.chooseSplitKind(set.first), + showSelectedIcon: false, + ); + }, ), const SizedBox(height: 8), - for (final member in ledger.members) - Row( - children: [ - Expanded( - child: CheckboxListTile( - contentPadding: EdgeInsets.zero, - controlAffinity: ListTileControlAffinity.leading, - value: form.participants.contains(member.id), - title: Text( - ledger.nameOfMember(member) + - (member.id == ledger.me?.id ? ' (you)' : ''), - ), - subtitle: switch (preview?[member.id]) { - final owed? => Text( - formatMoney(currency, owed), - style: moneyStyle(Theme.of(context).textTheme.bodySmall!), - ), + for (final member in _membersForEntry(ledger, form)) + _AllocationRow( + title: + ledger.nameOfMember(member) + + (member.id == ledger.me?.id ? ' (you)' : ''), + selected: form.participants.contains(member.id), + subtitle: form.splitKind == SplitKind.exact + ? null + : switch (preview?[member.id]) { + final owed? => formatMoney(currency, owed), null => null, }, - onChanged: (checked) => controller.setInSplit( - member.id, - included: checked ?? false, - ), - ), - ), - if (form.participants.contains(member.id)) - // Keyed by kind as well as person: an amount and a percentage - // sit in the same place, and must not share a field's text. - switch (form.splitKind) { - SplitKind.exact => SizedBox( - width: 110, - child: TextFormField( + onChanged: (checked) => + controller.setInSplit(member.id, included: checked), + input: !form.participants.contains(member.id) + ? null + : switch (form.splitKind) { + SplitKind.exact => RemainderField( key: ValueKey(('exact', member.id)), - initialValue: form.exactAmounts[member.id], - keyboardType: const TextInputType.numberWithOptions( - decimal: true, - ), + label: 'Amount', + memberName: ledger.nameOfMember(member), + manualText: form.exactAmounts[member.id], + automaticText: switch (allocation?.values[member.id]) { + final value? => currency?.formatPlain(value), + null => null, + }, + prefixText: currency?.symbol, + invalid: allocation?.invalid.contains(member.id) ?? false, inputFormatters: [DecimalInputFormatter.amount(currency)], - decoration: InputDecoration( - isDense: true, - prefixText: currency?.symbol, - ), onChanged: (text) => controller.setExactAmount(member.id, text), ), - ), - SplitKind.percent => SizedBox( - width: 90, - child: TextFormField( + SplitKind.percent => RemainderField( key: ValueKey(('percent', member.id)), - initialValue: form.percents[member.id], - keyboardType: const TextInputType.numberWithOptions( - decimal: true, - ), + label: 'Percent', + memberName: ledger.nameOfMember(member), + manualText: form.percents[member.id], + automaticText: switch (allocation?.values[member.id]) { + final value? => formatPercentMicros(value), + null => null, + }, + suffixText: '%', + invalid: allocation?.invalid.contains(member.id) ?? false, inputFormatters: [ DecimalInputFormatter(percentInputPattern), ], - decoration: const InputDecoration( - isDense: true, - suffixText: '%', - ), onChanged: (text) => controller.setPercent(member.id, text), ), - ), - SplitKind.shares => Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(Icons.remove_circle_outline), - onPressed: () => controller.nudgeShares(member.id, -1), - ), - Text('${form.shareCountOf(member.id)}'), - IconButton( - icon: const Icon(Icons.add_circle_outline), - onPressed: () => controller.nudgeShares(member.id, 1), - ), - ], - ), - SplitKind.equal || - SplitKind.unknownDefaultOpenApi => const SizedBox.shrink(), - }, - ], + SplitKind.shares => Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + tooltip: + 'Fewer shares for ${ledger.nameOfMember(member)}', + icon: const Icon(Icons.remove_circle_outline), + onPressed: () => + controller.nudgeShares(member.id, -1), + ), + Text('${form.shareCountOf(member.id)}'), + IconButton( + tooltip: + 'More shares for ${ledger.nameOfMember(member)}', + icon: const Icon(Icons.add_circle_outline), + onPressed: () => controller.nudgeShares(member.id, 1), + ), + ], + ), + _ => null, + }, + ), + if (form.splitKind == SplitKind.exact || + form.splitKind == SplitKind.percent) + _AllocationStatus( + error: currency == null ? null : form.splitError(currency), + hasOverrides: form.participants.any( + (form.splitKind == SplitKind.exact + ? form.exactAmounts + : form.percents) + .containsKey, + ), + onReset: controller.resetSplit, + ), + ], + ); + } +} + +/// A selection and its field wrap vertically when text needs more room. +class _AllocationRow extends StatelessWidget { + const _AllocationRow({ + required this.title, + required this.selected, + required this.onChanged, + this.subtitle, + this.input, + }); + + final String title; + final bool selected; + final ValueChanged onChanged; + final String? subtitle; + final Widget? input; + + @override + Widget build(BuildContext context) { + final selection = CheckboxListTile( + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + value: selected, + title: Text(title), + subtitle: subtitle == null + ? null + : Text( + subtitle!, + style: moneyStyle(Theme.of(context).textTheme.bodySmall!), + ), + onChanged: (checked) => onChanged(checked ?? false), + ); + final input = this.input; + if (input == null) return selection; + return LayoutBuilder( + builder: (context, constraints) { + final stacked = + constraints.maxWidth < 320 || + MediaQuery.textScalerOf(context).scale(16) > 20; + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: stacked + ? Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [selection, input], + ) + : Row( + children: [ + Expanded(child: selection), + SizedBox(width: 168, child: input), + ], + ), + ); + }, + ); + } +} + +class _AllocationStatus extends StatelessWidget { + const _AllocationStatus({ + required this.error, + required this.hasOverrides, + required this.onReset, + this.resetLabel = 'Split evenly', + }); + + final String? error; + final bool hasOverrides; + final VoidCallback onReset; + final String resetLabel; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (error case final message?) + Semantics( + liveRegion: true, + child: Text( + message, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.error, + ), + ), + ), + if (hasOverrides) + TextButton.icon( + onPressed: onReset, + icon: const Icon(Icons.restart_alt), + label: Text(resetLabel), ), ], ); @@ -551,10 +710,15 @@ class _SplitSection extends StatelessWidget { /// The category, as a chip that opens the fixed global list. class _CategoryChip extends ConsumerWidget { - const _CategoryChip({required this.value, required this.onChanged}); + const _CategoryChip({ + required this.value, + required this.onChanged, + this.heroTag, + }); final String? value; final ValueChanged onChanged; + final Object? heroTag; @override Widget build(BuildContext context, WidgetRef ref) { @@ -562,10 +726,14 @@ class _CategoryChip extends ConsumerWidget { ref.watch(categoriesProvider).value ?? const []; final chosen = categories.where((c) => c.id == value).firstOrNull; + final icon = Icon( + chosen == null ? Icons.category_outlined : categoryIcon(chosen.icon), + color: Theme.of(context).colorScheme.primary, + ); return ActionChip( - avatar: Icon( - chosen == null ? Icons.category_outlined : categoryIcon(chosen.icon), - ), + avatar: heroTag == null + ? icon + : SharedElement(tag: heroTag!, child: icon), label: Text(chosen?.name ?? 'Uncategorised'), tooltip: 'Change the category', onPressed: () async { @@ -616,106 +784,6 @@ class _CategorySheet extends StatelessWidget { } } -/// The currency, as a tonal button beside the amount, opening Material's -/// search view over every currency the device knows. -class _CurrencyButton extends ConsumerWidget { - const _CurrencyButton({required this.code, required this.onChanged}); - - final String code; - final ValueChanged onChanged; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final currencies = ref.watch(currenciesProvider).value ?? const {}; - final codes = currencies.keys.toList()..sort(); - - return SearchAnchor( - viewHintText: 'Search currencies', - builder: (context, controller) => FilledButton.tonalIcon( - onPressed: controller.openView, - iconAlignment: IconAlignment.end, - icon: const Icon(Icons.arrow_drop_down), - label: Text(code), - ), - suggestionsBuilder: (context, controller) { - final query = controller.text.trim().toLowerCase(); - return [ - for (final option in codes) - if (query.isEmpty || - option.toLowerCase().contains(query) || - currencies[option]!.name.toLowerCase().contains(query)) - ListTile( - title: Text(currencies[option]!.name), - leading: SizedBox(width: 48, child: Text(option)), - selected: option == code, - onTap: () { - controller.closeView(option); - onChanged(option); - }, - ), - ]; - }, - ); - } -} - -/// What has already happened to this expense. -class _History extends ConsumerWidget { - const _History({required this.entryId, required this.ledger}); - - final String entryId; - final GroupLedger ledger; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final events = ref.watch(entryActivityProvider(entryId)).value; - if (events == null || events.isEmpty) return const SizedBox.shrink(); - - final theme = Theme.of(context); - final currency = ref - .watch(currenciesProvider) - .value?[ledger.group.defaultCurrency]; - - // See the note in ActivityScreen: a build with nowhere to sync to must not - // mark every line as unsynced. - final syncs = ref.watch(syncEngineProvider) != null; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('History', style: theme.textTheme.titleSmall), - const SizedBox(height: 8), - // Narrowed rather than switched: watchEntry asks only for this - // expense's own snapshots, so anything else here would be a bug in the - // query rather than a kind to render. - for (final event in events.whereType()) - Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '${ledger.nameOfActor(event.actorId)} ' - '${describeKind(event.kind)}' - '${syncs && event.isProvisional ? ' — not synced yet' : ''}', - style: theme.textTheme.bodyMedium, - ), - for (final change in event.changes) - Text( - '· ' - '${describeChange(change, currency: currency, memberNames: ledger.memberNames)}', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ], - ); - } -} - /// When on the day it happened: optional, and cleared or changed here. class _TimeChip extends StatelessWidget { const _TimeChip({ diff --git a/lib/presentation/screens/group_detail_screen.dart b/lib/presentation/screens/group_detail_screen.dart index e23d15c..942659f 100644 --- a/lib/presentation/screens/group_detail_screen.dart +++ b/lib/presentation/screens/group_detail_screen.dart @@ -1,3 +1,5 @@ +import 'dart:math' show max; + import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -13,12 +15,13 @@ import '../../domain/models/entry.dart'; import '../../domain/money_format.dart'; import '../navigation.dart'; import '../theme.dart'; -import '../widgets/avatar_view.dart'; +import '../widgets/group_avatar_hero.dart'; import '../widgets/balance_arrow.dart'; import '../widgets/balances_panel.dart'; import '../widgets/category_icon.dart'; import '../widgets/empty_state.dart'; import '../widgets/group_cover.dart'; +import '../widgets/shared_element.dart'; import '../widgets/group_pane.dart'; import '../widgets/group_standing.dart'; import '../widgets/offline_indicator.dart'; @@ -70,6 +73,7 @@ class GroupDetailScreen extends ConsumerWidget { final scaffold = Scaffold( floatingActionButton: FloatingActionButton.extended( + heroTag: null, onPressed: () => context.push('/g/$groupId/add'), icon: const Icon(Icons.add), label: const Text('Add expense'), @@ -113,9 +117,9 @@ class GroupDetailScreen extends ConsumerWidget { ), ); - // A TabBar needs a controller in scope, but only the narrow layout has - // tabs at all; the wide one shows both panes at once. - return wide ? scaffold : DefaultTabController(length: 2, child: scaffold); + // Keep the app bar and its focus nodes mounted across the breakpoint, + // including when this page is resizing during its exit transition. + return DefaultTabController(length: 2, child: scaffold); } } @@ -142,9 +146,11 @@ class _GroupAppBar extends StatelessWidget { return SliverAppBar( pinned: true, - expandedHeight: _GroupHeader.height + (tabs ? kTextTabBarHeight : 0), + expandedHeight: + _GroupHeader.height(context) + (tabs ? kTextTabBarHeight : 0), leading: BackButton(onPressed: () => goBack(context, '/')), - title: Text(ledger.group.name), + // FlexibleSpaceBar already supplies the scroll expansion state. + title: _CollapsedGroupName(name: ledger.group.name), actions: [ const OfflineIndicator(), if (kIsWeb) SyncRefreshButton.group(groupId), @@ -182,14 +188,41 @@ class _GroupAppBar extends StatelessWidget { measure: measure, ), ), - bottom: tabs - ? const TabBar( - tabs: [ - Tab(text: 'Expenses'), - Tab(text: 'Balances'), - ], - ) - : null, + // AppBar changes its toolbar ancestry when bottom becomes null. + // Keep the slot present so a resize preserves its focused controls. + bottom: PreferredSize( + preferredSize: Size.fromHeight(tabs ? kTextTabBarHeight : 0), + child: Visibility( + visible: tabs, + maintainState: true, + child: const TabBar( + tabs: [ + Tab(text: 'Expenses'), + Tab(text: 'Balances'), + ], + ), + ), + ), + ); + } +} + +/// The expanded name owns the shared element; only the collapsed toolbar +/// repeats it. Keeping this widget mounted also preserves app-bar focus. +class _CollapsedGroupName extends StatelessWidget { + const _CollapsedGroupName({required this.name}); + + final String name; + + @override + Widget build(BuildContext context) { + final settings = context + .dependOnInheritedWidgetOfExactType(); + final collapsed = + settings == null || settings.currentExtent <= settings.minExtent + 8; + return Offstage( + offstage: !collapsed, + child: Text(name, maxLines: 1, overflow: TextOverflow.ellipsis), ); } } @@ -219,11 +252,16 @@ class _GroupHeader extends ConsumerWidget { static const _coverBelowToolbar = 64.0; static const _avatarRadius = 32.0; - /// Room for the standing, under the cover. - static const _standingHeight = 72.0; - /// The expanded height, toolbar included and tabs not. - static const height = 64 + _coverBelowToolbar + _standingHeight; + static double height(BuildContext context) => + 64 + + _coverBelowToolbar + + max( + 112, + MediaQuery.textScalerOf(context).scale(24) * 2.4 + + MediaQuery.textScalerOf(context).scale(16) * 2.4 + + 24, + ); @override Widget build(BuildContext context, WidgetRef ref) { @@ -244,7 +282,7 @@ class _GroupHeader extends ConsumerWidget { left: 0, right: 0, height: coverHeight, - child: GroupCover(ledger: ledger), + child: GroupCoverHero(ledger: ledger), ), PositionedDirectional( start: start, @@ -257,12 +295,7 @@ class _GroupHeader extends ConsumerWidget { child: CircleAvatar( radius: _avatarRadius + ring, backgroundColor: scheme.surface, - child: AvatarView( - avatar: group.avatar, - name: group.name, - id: group.id, - radius: _avatarRadius, - ), + child: GroupAvatarHero(group: group, radius: _avatarRadius), ), ), ), @@ -272,10 +305,20 @@ class _GroupHeader extends ConsumerWidget { end: 16, top: coverHeight + 12, bottom: bottomInset, - child: GroupStanding( - ledger: ledger, - currencies: currencies, - style: Theme.of(context).textTheme.titleMedium, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SharedText( + tag: ('group-name', group.id), + text: group.name, + style: Theme.of( + context, + ).textTheme.headlineSmall!.copyWith(color: scheme.onSurface), + maxLines: 2, + ), + const SizedBox(height: 4), + GroupStanding(ledger: ledger, currencies: currencies), + ], ), ), ], @@ -400,6 +443,7 @@ class _EntryTile extends StatelessWidget { final scheme = Theme.of(context).colorScheme; final me = ledger.me; final isSettlement = entry.row.kind == EntryKind.settlement; + final largeText = MediaQuery.textScalerOf(context).scale(12) > 16; // What this entry did to your position: what you paid, less what you owe. var myDelta = 0; @@ -430,10 +474,10 @@ class _EntryTile extends StatelessWidget { final when = at == null ? '' : ' · ${DateFormat.jm().format(at.toLocal())}'; final String subtitle; if (isSettlement) { - final payee = entry.shares.isEmpty - ? '—' - : ledger.nameOf(entry.shares.first.memberId); - subtitle = '$payerNames paid $payee$when'; + final note = entry.row.notes?.trim(); + final legacy = entry.row.description.trim(); + final detail = note?.isNotEmpty == true ? note! : legacy; + subtitle = detail.isEmpty ? 'Payment$when' : '$detail$when'; } else { subtitle = '$payerNames paid$when'; } @@ -449,32 +493,57 @@ class _EntryTile extends StatelessWidget { leading: CircleAvatar( backgroundColor: background, foregroundColor: foreground, - child: Icon( - isSettlement - ? Icons.handshake_outlined - : categoryIcon(category?.icon ?? ''), + child: SharedElement( + tag: ('entry-category', entry.id), + child: Icon( + isSettlement + ? Icons.handshake_outlined + : categoryIcon(category?.icon ?? ''), + color: foreground, + ), ), ), - title: Text( - isSettlement && entry.row.description.isEmpty - ? 'Settlement' + title: SharedText( + tag: ('entry-description', entry.id), + text: isSettlement + ? '$payerNames paid ${entry.shares.isEmpty ? '—' : ledger.nameOf(entry.shares.first.memberId)}' : entry.row.description.isEmpty ? 'Expense' : entry.row.description, - maxLines: 1, - overflow: TextOverflow.ellipsis, + style: Theme.of( + context, + ).textTheme.titleMedium!.copyWith(color: scheme.onSurface), + ), + isThreeLine: largeText && me != null && myDelta != 0, + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(subtitle, maxLines: 1, overflow: TextOverflow.ellipsis), + if (largeText && me != null && myDelta != 0) + BalanceAmount( + balanceMinor: myDelta, + text: myDeltaWords, + semanticsLabel: myDeltaWords, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600), + ), + ], ), - subtitle: Text(subtitle, maxLines: 1, overflow: TextOverflow.ellipsis), trailing: Column( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.end, children: [ - Text( - formatMoney(currency, entry.row.amountMinor), - style: moneyStyle(Theme.of(context).textTheme.titleMedium!), + SharedText( + tag: ('entry-amount', entry.id), + text: formatMoney(currency, entry.row.amountMinor), + style: moneyStyle( + Theme.of(context).textTheme.titleMedium!, + ).copyWith(color: scheme.onSurface), ), - if (me != null && myDelta != 0) + if (!largeText && me != null && myDelta != 0) BalanceAmount( balanceMinor: myDelta, text: myDeltaWords, diff --git a/lib/presentation/screens/group_list_screen.dart b/lib/presentation/screens/group_list_screen.dart index 2db7d50..2b4b0ee 100644 --- a/lib/presentation/screens/group_list_screen.dart +++ b/lib/presentation/screens/group_list_screen.dart @@ -8,7 +8,9 @@ import '../../application/sync_providers.dart'; import '../../data/local/database.dart'; import '../../data/repositories/drift_group_repository.dart'; import '../launch_hold.dart'; -import '../widgets/avatar_view.dart'; +import '../widgets/group_avatar_hero.dart'; +import '../widgets/group_cover.dart'; +import '../widgets/shared_element.dart'; import '../widgets/brand_mark.dart'; import '../widgets/conflicting_edit_banner.dart'; import '../widgets/create_group_sheet.dart'; @@ -57,6 +59,7 @@ class GroupListScreen extends ConsumerWidget { // ever true during a brand-new install's first sweep or on a rebuilt // device with no connection. floatingActionButton: FloatingActionButton.extended( + heroTag: null, onPressed: ref.watch(referenceDataProvider).value ?? false ? () => showCreateGroupSheet(context) : null, @@ -120,7 +123,13 @@ abstract final class _GroupList { itemBuilder: (context, index) => Segment( index: index, count: groups.length, - child: _GroupTile(group: groups[index]), + child: _GroupTile( + group: groups[index], + borderRadius: Segment.radiusAt( + index: index, + count: groups.length, + ), + ), ), ), ), @@ -154,9 +163,10 @@ class _ArchivedRow extends StatelessWidget { } class _GroupTile extends ConsumerWidget { - const _GroupTile({required this.group}); + const _GroupTile({required this.group, required this.borderRadius}); final Group group; + final BorderRadius borderRadius; @override Widget build(BuildContext context, WidgetRef ref) { @@ -164,20 +174,44 @@ class _GroupTile extends ConsumerWidget { final currencies = ref.watch(currenciesProvider).value ?? const {}; // A real ListTile, for its touch target, density and large-font growth. - return ListTile( - onTap: () => context.push('/g/${group.id}'), - contentPadding: const EdgeInsetsDirectional.fromSTEB(16, 8, 24, 8), - leading: AvatarView( - avatar: group.avatar, - name: group.name, - id: group.id, - radius: 24, - ), - title: Text(group.name, overflow: TextOverflow.ellipsis), - titleTextStyle: Theme.of(context).textTheme.titleMedium, - subtitle: ledger == null - ? const LaunchPlaceholder(child: SizedBox(height: 20)) - : GroupStanding(ledger: ledger, currencies: currencies), + return Stack( + children: [ + if (ledger != null) + Positioned.fill( + child: GroupCoverHero( + ledger: ledger, + borderRadius: borderRadius, + patternFade: 1, + ), + ), + Material( + type: MaterialType.transparency, + child: ListTile( + onTap: () => context.push('/g/${group.id}'), + contentPadding: const EdgeInsetsDirectional.fromSTEB( + 16, + 12, + 24, + 12, + ), + leading: CircleAvatar( + radius: 24, + backgroundColor: Theme.of(context).colorScheme.surface, + child: GroupAvatarHero(group: group, radius: 22), + ), + title: SharedText( + tag: ('group-name', group.id), + text: group.name, + style: Theme.of(context).textTheme.titleMedium!.copyWith( + color: Theme.of(context).colorScheme.onSurface, + ), + ), + subtitle: ledger == null + ? const LaunchPlaceholder(child: SizedBox(height: 20)) + : GroupStanding(ledger: ledger, currencies: currencies), + ), + ), + ], ); } } diff --git a/lib/presentation/screens/members_screen.dart b/lib/presentation/screens/members_screen.dart index 6d7c89d..5b3e31d 100644 --- a/lib/presentation/screens/members_screen.dart +++ b/lib/presentation/screens/members_screen.dart @@ -29,6 +29,7 @@ class MembersScreen extends ConsumerWidget { title: const Text('People'), ), floatingActionButton: FloatingActionButton.extended( + heroTag: null, onPressed: () => _addMember(context, ref), icon: const Icon(Icons.person_add_outlined), label: const Text('Add person'), diff --git a/lib/presentation/screens/settings_screen.dart b/lib/presentation/screens/settings_screen.dart index 5e21ade..6baf061 100644 --- a/lib/presentation/screens/settings_screen.dart +++ b/lib/presentation/screens/settings_screen.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart' show kIsWeb; import 'package:material_ui/material_ui.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -234,24 +235,42 @@ class _WallpaperSetting extends ConsumerWidget { // Still asking the platform. A single frame in practice, and showing // "not available" during it would be a lie that then corrects itself. - if (schemes.isLoading) return const SizedBox.shrink(); + if (schemes.isLoading && !schemes.hasValue) { + return const SizedBox.shrink(); + } final available = schemes.value; if (available == null) { - return ListTile( - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.palette_outlined), - title: const Text('Wallpaper colours are not available here'), - subtitle: const Text( - 'Android 12 and later hand apps a palette taken from your ' - 'wallpaper. This device does not offer one, so OpenSplit uses its ' - 'own — which is also what every browser gets.', - ), - isThreeLine: true, - enabled: false, + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.palette_outlined), + title: const Text('Using OpenSplit colours'), + subtitle: Text( + kIsWeb + ? 'Browsers use OpenSplit colours. The Android app can ' + 'use your device palette.' + : 'Your device has not provided a colour palette. On ' + 'Samsung, check Settings → Wallpaper and style → ' + 'Colour palette.', + ), + ), + if (!kIsWeb) + TextButton.icon( + onPressed: () => ref.invalidate(wallpaperSchemesProvider), + icon: const Icon(Icons.refresh), + label: const Text('Check again'), + ), + ], ); } + final palette = Theme.of(context).brightness == Brightness.dark + ? available.dark + : available.light; + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -262,10 +281,7 @@ class _WallpaperSetting extends ConsumerWidget { ref.read(wallpaperColorsProvider.notifier).set(enabled: wanted), secondary: const Icon(Icons.palette_outlined), title: const Text('Match my wallpaper'), - subtitle: const Text( - 'Use the colours Android takes from your wallpaper, instead of ' - "OpenSplit's own.", - ), + subtitle: const Text('Use your device palette throughout OpenSplit.'), ), const SizedBox(height: 8), // The palette itself, so that "it is on and nothing changed" is @@ -273,10 +289,10 @@ class _WallpaperSetting extends ConsumerWidget { Row( children: [ for (final swatch in [ - available.light.primary, - available.light.primaryContainer, - available.light.secondary, - available.light.tertiary, + palette.primary, + palette.primaryContainer, + palette.secondary, + palette.tertiary, ]) Container( width: 28, @@ -290,8 +306,8 @@ class _WallpaperSetting extends ConsumerWidget { Expanded( child: Text( enabled - ? "What your wallpaper offers, and what you are seeing." - : 'What your wallpaper offers. Not in use.', + ? 'Your device colours are in use.' + : 'Your device colours. OpenSplit colours are in use.', style: text.bodySmall?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, ), diff --git a/lib/presentation/screens/settle_up_screen.dart b/lib/presentation/screens/settle_up_screen.dart index 4957315..53359a4 100644 --- a/lib/presentation/screens/settle_up_screen.dart +++ b/lib/presentation/screens/settle_up_screen.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart' show kIsWeb; import 'package:material_ui/material_ui.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; import 'package:qr_flutter/qr_flutter.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -14,16 +15,17 @@ import '../../application/sync_providers.dart'; import '../../data/local/database.dart'; import '../../domain/money_format.dart'; import '../../domain/settle/upi.dart'; -import '../amount_input.dart'; import '../navigation.dart'; -import '../widgets/currency_picker.dart'; +import '../feedback.dart'; +import '../widgets/amount_card.dart'; +import '../widgets/entry_history.dart'; import '../widgets/page_body.dart'; +import '../widgets/shared_element.dart'; -/// Records a payment between two members, optionally handing off to a UPI app -/// first. +/// Records or edits a payment between two members. /// -/// Holds no state of its own: what has been chosen and typed lives in -/// [SettlementForm]. +/// UPI handoff is available when recording a new payment. Editing changes the +/// existing record, with the state held by [SettlementForm]. class SettleUpScreen extends ConsumerWidget { const SettleUpScreen({ super.key, @@ -32,6 +34,7 @@ class SettleUpScreen extends ConsumerWidget { this.toMemberId, this.amountMinor, this.currency, + this.entryId, }); final String groupId; @@ -39,6 +42,7 @@ class SettleUpScreen extends ConsumerWidget { final String? toMemberId; final int? amountMinor; final String? currency; + final String? entryId; @override Widget build(BuildContext context, WidgetRef ref) { @@ -48,7 +52,16 @@ class SettleUpScreen extends ConsumerWidget { toId: toMemberId, amountMinor: amountMinor, currency: currency, + entryId: entryId, ); + ref.listen(provider, (previous, next) { + final error = next.value?.error; + if (error != null && error != previous?.value?.error) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(error))); + } + }); // Started on the way in, so this device's zone is known by the time a // settlement is recorded. @@ -67,6 +80,13 @@ class SettleUpScreen extends ConsumerWidget { ledger: ledger, form: form, controller: ref.read(provider.notifier), + transferIdentity: + fromMemberId != null && + toMemberId != null && + currency != null && + amountMinor != null + ? (groupId, fromMemberId!, toMemberId!, currency!) + : null, ); } } @@ -76,15 +96,18 @@ class _SettleUp extends ConsumerWidget { required this.ledger, required this.form, required this.controller, + required this.transferIdentity, }); final GroupLedger ledger; final SettlementFormState form; final SettlementForm controller; + final Object? transferIdentity; Future _record(BuildContext context, WidgetRef ref) async { if (!await controller.record() || !context.mounted) return; goBack(context, '/g/${ledger.group.id}'); + if (form.isEditing) return; // The one place the app asks for a review: a debt has just been cleared, // which is the app finishing the thing it exists to do. @@ -92,6 +115,35 @@ class _SettleUp extends ConsumerWidget { unawaited(review.isDue().then((due) => due ? review.ask() : null)); } + Future _delete(BuildContext context) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete this payment?'), + content: const Text( + 'This removes the payment from the balances and keeps its history.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + confirmedIrreversibly(); + Navigator.of(context).pop(true); + }, + child: const Text('Delete'), + ), + ], + ), + ); + if (confirmed != true || !context.mounted) return; + if (await controller.delete() && context.mounted) { + goBack(context, '/g/${ledger.group.id}'); + } + } + @override Widget build(BuildContext context, WidgetRef ref) { final currencies = ref.watch(currenciesProvider).value ?? const {}; @@ -99,21 +151,96 @@ class _SettleUp extends ConsumerWidget { final payee = form.toId == null ? null : ledger.memberById(form.toId!); final payeeName = payee == null ? '' : ledger.nameOfMember(payee); final (from, to) = (form.fromId, form.toId); + final editing = form.editing; + final ready = !form.saving && currency != null; + // Keep a recorded payment's participants selectable even after they left + // and their balance reached zero. + final members = { + for (final member in ledger.settleable) member.id: member, + if (editing != null) + for (final id in [ + ...editing.payers.map((p) => p.memberId), + ...editing.shares.map((s) => s.memberId), + ]) + id: ?ledger.memberById(id), + }.values.toList(); return Scaffold( appBar: AppBar( leading: BackButton( onPressed: () => goBack(context, '/g/${ledger.group.id}'), ), - title: const Text('Settle up'), + title: Text(form.isEditing ? 'Edit payment' : 'Settle up'), + actions: [ + if (form.isEditing) ...[ + IconButton( + tooltip: 'Delete payment', + onPressed: form.saving ? null : () => _delete(context), + icon: const Icon(Icons.delete_outline), + ), + Padding( + padding: const EdgeInsets.only(right: 8), + child: FilledButton( + onPressed: ready ? () => _record(context, ref) : null, + child: const Text('Save'), + ), + ), + ], + ], ), body: PageBody( + maxWidth: 640, child: ListView( padding: const EdgeInsets.fromLTRB(16, 16, 16, 32), children: [ + if (editing != null) ...[ + Row( + children: [ + SharedElement( + tag: ('entry-category', editing.id), + child: Icon( + Icons.handshake_outlined, + color: Theme.of(context).colorScheme.onTertiaryContainer, + ), + ), + const SizedBox(width: 12), + Expanded( + child: SharedText( + tag: ('entry-description', editing.id), + text: + '${ledger.nameOf(from ?? '')} paid ' + '${ledger.nameOf(to ?? '')}', + style: Theme.of(context).textTheme.titleLarge!.copyWith( + color: Theme.of(context).colorScheme.onSurface, + ), + maxLines: 2, + ), + ), + ], + ), + const SizedBox(height: 24), + ], + AmountCard( + heroTag: editing != null + ? ('entry-amount', editing.id) + : transferIdentity == null + ? null + : ('settlement-amount', transferIdentity!), + currency: currency, + currencyCode: form.currencyCode, + amount: form.amount, + label: 'Amount', + error: form.error, + onAmountChanged: controller.setAmount, + onCurrencyChanged: controller.chooseCurrency, + ), + const SizedBox(height: 24), _MemberDropdown( - label: 'Who is paying', - members: ledger.settleable, + heroTag: transferIdentity == null + ? null + : ('settlement-from', transferIdentity!), + label: form.isEditing ? 'Paid by' : 'Who is paying', + members: members, nameOf: ledger.nameOfMember, meId: ledger.me?.id, value: from, @@ -121,8 +248,11 @@ class _SettleUp extends ConsumerWidget { ), const SizedBox(height: 16), _MemberDropdown( - label: 'Who is being paid', - members: ledger.settleable, + heroTag: transferIdentity == null + ? null + : ('settlement-to', transferIdentity!), + label: form.isEditing ? 'Paid to' : 'Who is being paid', + members: members, nameOf: ledger.nameOfMember, meId: ledger.me?.id, value: to, @@ -130,27 +260,10 @@ class _SettleUp extends ConsumerWidget { onChanged: controller.choosePayee, ), const SizedBox(height: 16), - CurrencyPicker( - value: form.currencyCode, - onChanged: controller.chooseCurrency, - ), - const SizedBox(height: 16), - TextFormField( - initialValue: form.amount, - keyboardType: const TextInputType.numberWithOptions( - decimal: true, - ), - inputFormatters: [DecimalInputFormatter.amount(currency)], - decoration: InputDecoration( - labelText: 'Amount', - prefixText: currency?.symbol == null - ? null - : '${currency!.symbol} ', - errorText: form.error, - ), - onChanged: controller.setAmount, - ), - if (from != null && to != null && currency != null) ...[ + if (!form.isEditing && + from != null && + to != null && + currency != null) ...[ const SizedBox(height: 8), _OutstandingHint( ledger: ledger, @@ -159,8 +272,12 @@ class _SettleUp extends ConsumerWidget { currency: currency, ), ], + const SizedBox(height: 8), + _PaymentDetails(form: form, controller: controller), const SizedBox(height: 24), - if (currency != null && currency.code == 'INR') ...[ + if (!form.isEditing && + currency != null && + currency.code == 'INR') ...[ _UpiSection( payeeName: payeeName, // A new payee, or their handle arriving with their profile, @@ -179,19 +296,24 @@ class _SettleUp extends ConsumerWidget { ), const SizedBox(height: 24), ], - FilledButton.icon( - onPressed: form.saving || currency == null - ? null - : () => _record(context, ref), - icon: const Icon(Icons.check), - label: const Text('Record this payment'), - ), + if (!form.isEditing) + FilledButton.icon( + onPressed: form.saving || currency == null + ? null + : () => _record(context, ref), + icon: const Icon(Icons.check), + label: const Text('Record this payment'), + ), const SizedBox(height: 12), Text( 'OpenSplit does not move or check money. Recording a payment ' 'here only updates the balances in this app.', style: Theme.of(context).textTheme.bodySmall, ), + if (editing != null) ...[ + const SizedBox(height: 24), + EntryHistory(entryId: editing.id, ledger: ledger), + ], ], ), ), @@ -199,6 +321,105 @@ class _SettleUp extends ConsumerWidget { } } +/// The optional context shared by recording and editing a payment. +class _PaymentDetails extends ConsumerWidget { + const _PaymentDetails({required this.form, required this.controller}); + + final SettlementFormState form; + final SettlementForm controller; + + Future _pickDay(BuildContext context) async { + final day = form.day ?? DateTime.now(); + final picked = await showDatePicker( + context: context, + initialDate: DateTime(day.year, day.month, day.day), + firstDate: DateTime(2000), + lastDate: DateTime.now().add(const Duration(days: 365)), + ); + if (picked != null) controller.chooseDay(picked); + } + + Future _pickTime(BuildContext context, String zone) async { + final picked = await showTimePicker( + context: context, + initialTime: TimeOfDay.fromDateTime( + form.occurredAt?.toLocal() ?? DateTime.now(), + ), + ); + if (picked != null) { + controller.chooseTime( + hour: picked.hour, + minute: picked.minute, + zone: zone, + ); + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final deviceZone = ref.watch(deviceZoneProvider).value; + final time = form.includeTime ? form.occurredAt?.toLocal() : null; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Align( + alignment: AlignmentDirectional.centerStart, + child: ActionChip( + avatar: const Icon(Icons.event_outlined), + label: Text(DateFormat.yMMMd().format(form.day ?? DateTime.now())), + tooltip: 'Change the day', + onPressed: () => _pickDay(context), + ), + ), + const SizedBox(height: 16), + TextFormField( + initialValue: form.notes, + textCapitalization: TextCapitalization.sentences, + maxLength: 2000, + minLines: 1, + maxLines: 3, + decoration: const InputDecoration( + labelText: 'Payment note (optional)', + hintText: 'Paid in cash, bank reference…', + counterText: '', + ), + onChanged: controller.setNotes, + ), + const SizedBox(height: 8), + ExpansionTile( + title: const Text('Time (optional)'), + subtitle: Text( + time == null ? 'Date only' : DateFormat.jm().format(time), + ), + leading: const Icon(Icons.schedule_outlined), + childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + expandedCrossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (time == null) + ActionChip( + avatar: const Icon(Icons.schedule_outlined), + label: const Text('Add a time'), + onPressed: deviceZone == null + ? null + : () => _pickTime(context, deviceZone), + ) + else + InputChip( + avatar: const Icon(Icons.schedule_outlined), + label: Text(DateFormat.jm().format(time)), + onPressed: deviceZone == null + ? null + : () => _pickTime(context, deviceZone), + onDeleted: controller.clearTime, + deleteButtonTooltipMessage: 'Remove the time', + ), + ], + ), + ], + ); + } +} + /// Shows what is actually outstanding between the two chosen members, so a /// typed amount can be sanity-checked against it. class _OutstandingHint extends StatelessWidget { @@ -387,6 +608,7 @@ class _MemberDropdown extends StatelessWidget { required this.onChanged, this.meId, this.excludeId, + this.heroTag, }); final String label; @@ -399,6 +621,7 @@ class _MemberDropdown extends StatelessWidget { final String? value; final String? meId; final String? excludeId; + final Object? heroTag; final ValueChanged onChanged; @override @@ -408,7 +631,7 @@ class _MemberDropdown extends StatelessWidget { if (member.id != excludeId) member, ]; - return DropdownMenu( + final dropdown = DropdownMenu( // DropdownMenu reads its selection once, and on later changes only // follows one it can find among its entries: cleared to nobody, it // would go on showing the last name. Keyed by the value, a different @@ -429,5 +652,15 @@ class _MemberDropdown extends StatelessWidget { ], onSelected: onChanged, ); + final selected = options.where((member) => member.id == value).firstOrNull; + if (heroTag == null || selected == null) return dropdown; + return SharedText( + tag: heroTag!, + text: nameOf(selected) + (selected.id == meId ? ' (you)' : ''), + style: Theme.of(context).textTheme.bodyLarge!.copyWith( + color: Theme.of(context).colorScheme.onSurface, + ), + child: dropdown, + ); } } diff --git a/lib/presentation/theme.dart b/lib/presentation/theme.dart index 8e683bf..bfcd5c2 100644 --- a/lib/presentation/theme.dart +++ b/lib/presentation/theme.dart @@ -6,10 +6,10 @@ import 'package:opensplit_api/opensplit_api.dart' show AvatarColor; import '../domain/avatar.dart'; /// The app's visual identity. -const _seed = Color(0xFF5B5891); +const _seed = Colors.indigo; /// Source colour for "owed to you", before harmonisation. -const _creditSource = Color(0xFF2E7D32); +final _creditSource = Colors.green.shade800; /// Colours for a balance figure, which [ColorScheme] has no role for. @immutable @@ -53,7 +53,7 @@ class BalanceColors extends ThemeExtension { /// roles from it: an avatar its `primaryContainer` pair, a cover more. @immutable class AvatarPalette extends ThemeExtension { - const AvatarPalette(this._schemes); + const AvatarPalette(this._schemes, this._appScheme); factory AvatarPalette.from(ColorScheme scheme) => AvatarPalette({ for (final hue in avatarHues) @@ -61,9 +61,25 @@ class AvatarPalette extends ThemeExtension { seedColor: _hueSources[hue]!.harmonizeWith(scheme.primary), brightness: scheme.brightness, ), - }); + }, scheme); final Map _schemes; + final ColorScheme _appScheme; + + /// Automatic avatars use the app's accent containers, including wallpaper + /// colours. An explicitly chosen hue keeps its own harmonised palette. + ColorScheme forAvatar(Avatar avatar, String id) { + if (avatar.color case final hue?) return this[hue]; + final (container, onContainer) = switch (stableHash(id) % 3) { + 0 => (_appScheme.primaryContainer, _appScheme.onPrimaryContainer), + 1 => (_appScheme.secondaryContainer, _appScheme.onSecondaryContainer), + _ => (_appScheme.tertiaryContainer, _appScheme.onTertiaryContainer), + }; + return _appScheme.copyWith( + primaryContainer: container, + onPrimaryContainer: onContainer, + ); + } /// The scheme for [hue]. ColorScheme operator [](AvatarColor hue) => @@ -86,20 +102,20 @@ class AvatarPalette extends ThemeExtension { return AvatarPalette({ for (final hue in avatarHues) hue: ColorScheme.lerp(this[hue], other[hue], t), - }); + }, ColorScheme.lerp(_appScheme, other._appScheme, t)); } } -/// Source colours for the avatar hues, before harmonisation. -const _hueSources = { - AvatarColor.purple: Color(0xFF7E57C2), - AvatarColor.blue: Color(0xFF1E88E5), - AvatarColor.teal: Color(0xFF00897B), - AvatarColor.green: Color(0xFF43A047), - AvatarColor.olive: Color(0xFF9E9D24), - AvatarColor.amber: Color(0xFFFFB300), - AvatarColor.orange: Color(0xFFF4511E), - AvatarColor.pink: Color(0xFFD81B60), +/// Material swatches for the avatar hues, before harmonisation. +final _hueSources = { + AvatarColor.purple: Colors.deepPurple.shade400, + AvatarColor.blue: Colors.blue.shade600, + AvatarColor.teal: Colors.teal.shade600, + AvatarColor.green: Colors.green.shade600, + AvatarColor.olive: Colors.lime.shade800, + AvatarColor.amber: Colors.amber.shade600, + AvatarColor.orange: Colors.deepOrange.shade600, + AvatarColor.pink: Colors.pink.shade600, }; /// Builds the theme, optionally from a wallpaper-derived scheme. @@ -108,11 +124,19 @@ ThemeData buildTheme(Brightness brightness, [ColorScheme? dynamicScheme]) { dynamicScheme?.harmonized() ?? ColorScheme.fromSeed(seedColor: _seed, brightness: brightness); + // DropdownMenu has its own decoration defaults and does not inherit the + // app's field theme. Supply the same policy to both component families. + const fields = InputDecorationThemeData( + filled: true, + border: UnderlineInputBorder(), + helperMaxLines: 3, + errorMaxLines: 3, + ); + final base = ThemeData( colorScheme: scheme, visualDensity: VisualDensity.adaptivePlatformDensity, extensions: [BalanceColors.of(scheme), AvatarPalette.from(scheme)], - // No surfaceTintColor. appBarTheme: const AppBarThemeData(centerTitle: false), // Deliberately says nothing about elevation, colour or shape. cardTheme: const CardThemeData( @@ -126,17 +150,17 @@ ThemeData buildTheme(Brightness brightness, [ColorScheme? dynamicScheme]) { iconButtonTheme: const IconButtonThemeData( variant: StyleVariant.material3Expressive, ), - // Outlined, not filled. - inputDecorationTheme: const InputDecorationThemeData( - border: OutlineInputBorder(), + inputDecorationTheme: fields, + dropdownMenuTheme: const DropdownMenuThemeData( + inputDecorationTheme: fields, ), ); - // Instrument Sans, resolved from the bundle rather than the network — see the + // Google Sans Flex, resolved from the bundle rather than the network — see the // `fonts:`/`assets:` pair in pubspec.yaml and allowRuntimeFetching in // main.dart. return base.copyWith( - textTheme: GoogleFonts.instrumentSansTextTheme(base.textTheme), + textTheme: GoogleFonts.googleSansFlexTextTheme(base.textTheme), ); } diff --git a/lib/presentation/widgets/amount_card.dart b/lib/presentation/widgets/amount_card.dart new file mode 100644 index 0000000..72b688b --- /dev/null +++ b/lib/presentation/widgets/amount_card.dart @@ -0,0 +1,105 @@ +import 'package:material_ui/material_ui.dart'; + +import '../../data/local/database.dart'; +import '../../domain/money_format.dart'; +import '../amount_input.dart'; +import '../theme.dart'; +import 'currency_picker.dart'; +import 'shared_element.dart'; + +/// A transaction headline pairing its amount with a searchable currency. +class AmountCard extends StatelessWidget { + const AmountCard({ + super.key, + required this.currency, + required this.amount, + required this.currencyCode, + required this.onAmountChanged, + required this.onCurrencyChanged, + this.autofocus = false, + this.label = 'Amount', + this.error, + this.heroTag, + }); + + final Currency? currency; + final String amount; + final String currencyCode; + final ValueChanged onAmountChanged; + final ValueChanged onCurrencyChanged; + final bool autofocus; + final String label; + final String? error; + + /// The expense or suggested payment whose amount opens into this field. + final Object? heroTag; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final foreground = theme.colorScheme.onSecondaryContainer; + final valueStyle = moneyStyle( + theme.textTheme.displaySmall!, + ).copyWith(color: foreground); + final amountField = TextFormField( + initialValue: amount, + autofocus: autofocus, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [DecimalInputFormatter.amount(currency)], + textInputAction: TextInputAction.next, + style: valueStyle, + decoration: InputDecoration( + labelText: label, + errorText: error, + floatingLabelBehavior: FloatingLabelBehavior.always, + prefixStyle: valueStyle, + hintText: currency?.formatPlain(0) ?? '0', + prefixText: currency?.symbol == null ? null : '${currency!.symbol} ', + labelStyle: TextStyle(color: foreground), + floatingLabelStyle: TextStyle(color: foreground), + hintStyle: valueStyle, + filled: false, + border: const UnderlineInputBorder(), + contentPadding: const EdgeInsets.symmetric(vertical: 8), + ), + onChanged: onAmountChanged, + ); + final field = heroTag == null + ? amountField + : SharedText( + tag: heroTag!, + text: '${currency?.symbol ?? currencyCode} $amount', + style: valueStyle, + child: amountField, + ); + final currencyButton = CurrencyButton( + code: currencyCode, + onChanged: onCurrencyChanged, + ); + + return Card.filled( + color: theme.colorScheme.secondaryContainer, + child: Padding( + padding: const EdgeInsets.all(20), + child: LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth < 280 || + MediaQuery.textScalerOf(context).scale(16) > 24) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [currencyButton, const SizedBox(height: 12), field], + ); + } + return Row( + children: [ + Expanded(child: field), + const SizedBox(width: 12), + currencyButton, + ], + ); + }, + ), + ), + ); + } +} diff --git a/lib/presentation/widgets/avatar_view.dart b/lib/presentation/widgets/avatar_view.dart index 0e341b4..96bb998 100644 --- a/lib/presentation/widgets/avatar_view.dart +++ b/lib/presentation/widgets/avatar_view.dart @@ -27,7 +27,7 @@ class AvatarView extends StatelessWidget { @override Widget build(BuildContext context) { - final scheme = AvatarPalette.of(context)[avatar.color ?? hueFor(id)]; + final scheme = AvatarPalette.of(context).forAvatar(avatar, id); final textTheme = Theme.of(context).textTheme; final child = switch (avatar) { diff --git a/lib/presentation/widgets/balance_arrow.dart b/lib/presentation/widgets/balance_arrow.dart index 2afeba3..bb0544c 100644 --- a/lib/presentation/widgets/balance_arrow.dart +++ b/lib/presentation/widgets/balance_arrow.dart @@ -69,7 +69,7 @@ class BalanceAmount extends StatelessWidget { size: (resolved.fontSize ?? 14) * 1.1, ), if (balanceMinor != 0) const SizedBox(width: 2), - Text(text, style: resolved), + Flexible(child: Text(text, style: resolved)), ], ), ), diff --git a/lib/presentation/widgets/balances_panel.dart b/lib/presentation/widgets/balances_panel.dart index 4bc31de..ccb6d60 100644 --- a/lib/presentation/widgets/balances_panel.dart +++ b/lib/presentation/widgets/balances_panel.dart @@ -16,6 +16,7 @@ import 'balance_arrow.dart'; import 'empty_state.dart'; import 'group_pane.dart'; import 'segmented_list.dart'; +import 'shared_element.dart'; /// Per-currency balances and, when the group wants them, the payments that /// would settle it. @@ -320,9 +321,18 @@ class _TransferTile extends StatelessWidget { ); final scheme = Theme.of(context).colorScheme; + final textStyle = Theme.of( + context, + ).textTheme.bodyLarge!.copyWith(color: scheme.onSurface); final words = isMine ? 'You pay $to' : '$from pays ${transfer.toMemberId == ledger.me?.id ? 'you' : to}'; + final identity = ( + ledger.group.id, + transfer.fromMemberId, + transfer.toMemberId, + transfer.currency, + ); void settle() => context.push( '/g/${ledger.group.id}/settle' '?from=${transfer.fromMemberId}' @@ -340,22 +350,43 @@ class _TransferTile extends StatelessWidget { leading: other == null ? null : MemberAvatar(ledger: ledger, member: other), - title: Row( - children: [ - Flexible(child: Text(words, overflow: TextOverflow.ellipsis)), - const SizedBox(width: 6), - ExcludeSemantics( - child: Icon( - Icons.help_outline, - size: 16, - color: scheme.onSurfaceVariant, + title: Semantics( + label: words, + excludeSemantics: true, + child: Row( + children: [ + Flexible( + child: SharedText( + tag: ('settlement-from', identity), + text: isMine ? 'You' : from, + style: textStyle, + ), ), - ), - ], + Text(isMine ? ' pay ' : ' pays '), + Flexible( + child: SharedText( + tag: ('settlement-to', identity), + text: transfer.toMemberId == ledger.me?.id ? 'you' : to, + style: textStyle, + ), + ), + const SizedBox(width: 6), + ExcludeSemantics( + child: Icon( + Icons.help_outline, + size: 16, + color: scheme.onSurfaceVariant, + ), + ), + ], + ), ), - subtitle: Text( - formatMoney(currency, transfer.amountMinor), - style: moneyStyle(Theme.of(context).textTheme.titleMedium!), + subtitle: SharedText( + tag: ('settlement-amount', identity), + text: formatMoney(currency, transfer.amountMinor), + style: moneyStyle( + Theme.of(context).textTheme.titleMedium!, + ).copyWith(color: scheme.onSurface), ), trailing: FilledButton.tonal( onPressed: settle, diff --git a/lib/presentation/widgets/conflicting_edit_banner.dart b/lib/presentation/widgets/conflicting_edit_banner.dart index d0bed44..719de68 100644 --- a/lib/presentation/widgets/conflicting_edit_banner.dart +++ b/lib/presentation/widgets/conflicting_edit_banner.dart @@ -6,6 +6,7 @@ import '../../application/ledger_providers.dart'; import '../../application/local_providers.dart'; import '../../data/repositories/drift_conflict_repository.dart'; import '../../domain/money_format.dart'; +import 'inline_notice.dart'; /// Says that an edit did not apply, and what the expense says instead. class ConflictingEditBanner extends ConsumerWidget { @@ -20,39 +21,16 @@ class ConflictingEditBanner extends ConsumerWidget { final conflicts = ref.watch(pendingConflictsProvider).value ?? const []; if (conflicts.isEmpty) return const SizedBox.shrink(); - final scheme = Theme.of(context).colorScheme; - final text = Theme.of(context).textTheme; final first = conflicts.first; final count = conflicts.length; - return Padding( padding: padding, - child: MaterialBanner( - backgroundColor: scheme.tertiaryContainer, - contentTextStyle: text.bodyMedium?.copyWith( - color: scheme.onTertiaryContainer, - ), - leading: Icon( - Icons.published_with_changes_rounded, - color: scheme.onTertiaryContainer, - ), - content: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - count == 1 - ? 'Your change to “${first.attempted.description}” did not ' - 'apply' - : '$count of your changes did not apply', - style: text.titleSmall?.copyWith( - color: scheme.onTertiaryContainer, - ), - ), - const SizedBox(height: 4), - Text(_explain(ref, first, count)), - ], - ), + child: InlineNotice( + icon: Icons.published_with_changes_rounded, + title: count == 1 + ? 'Your change to “${first.attempted.description}” did not apply' + : '$count of your changes did not apply', + message: _explain(ref, first, count), actions: [ TextButton( onPressed: () => diff --git a/lib/presentation/widgets/currency_picker.dart b/lib/presentation/widgets/currency_picker.dart index 10db32b..7b5f0fc 100644 --- a/lib/presentation/widgets/currency_picker.dart +++ b/lib/presentation/widgets/currency_picker.dart @@ -43,3 +43,49 @@ class CurrencyPicker extends ConsumerWidget { ); } } + +/// Opens Material's search view over locally available currencies. +class CurrencyButton extends ConsumerWidget { + const CurrencyButton({ + super.key, + required this.code, + required this.onChanged, + }); + + final String code; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final currencies = ref.watch(currenciesProvider).value ?? const {}; + final codes = currencies.keys.toList()..sort(); + + return SearchAnchor( + viewHintText: 'Search currencies', + builder: (context, controller) => OutlinedButton.icon( + onPressed: controller.openView, + iconAlignment: IconAlignment.end, + icon: const Icon(Icons.arrow_drop_down), + label: Text(code), + ), + suggestionsBuilder: (context, controller) { + final query = controller.text.trim().toLowerCase(); + return [ + for (final option in codes) + if (query.isEmpty || + option.toLowerCase().contains(query) || + currencies[option]!.name.toLowerCase().contains(query)) + ListTile( + title: Text(currencies[option]!.name), + leading: SizedBox(width: 48, child: Text(option)), + selected: option == code, + onTap: () { + controller.closeView(option); + onChanged(option); + }, + ), + ]; + }, + ); + } +} diff --git a/lib/presentation/widgets/entry_history.dart b/lib/presentation/widgets/entry_history.dart new file mode 100644 index 0000000..cefa386 --- /dev/null +++ b/lib/presentation/widgets/entry_history.dart @@ -0,0 +1,64 @@ +import 'package:material_ui/material_ui.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../application/ledger_providers.dart'; +import '../../application/sync_providers.dart'; +import '../../domain/activity/activity_text.dart'; +import '../../domain/models/group_event.dart'; + +/// What has already happened to this entry. +class EntryHistory extends ConsumerWidget { + const EntryHistory({super.key, required this.entryId, required this.ledger}); + + final String entryId; + final GroupLedger ledger; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final events = ref.watch(entryActivityProvider(entryId)).value; + if (events == null || events.isEmpty) return const SizedBox.shrink(); + + final theme = Theme.of(context); + final currency = ref + .watch(currenciesProvider) + .value?[ledger.group.defaultCurrency]; + + // See the note in ActivityScreen: a build with nowhere to sync to must not + // mark every line as unsynced. + final syncs = ref.watch(syncEngineProvider) != null; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('History', style: theme.textTheme.titleSmall), + const SizedBox(height: 8), + // Narrowed rather than switched: watchEntry asks only for this + // expense's own snapshots, so anything else here would be a bug in the + // query rather than a kind to render. + for (final event in events.whereType()) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${ledger.nameOfActor(event.actorId)} ' + '${describeKind(event.kind)}' + '${syncs && event.isProvisional ? ' — not synced yet' : ''}', + style: theme.textTheme.bodyMedium, + ), + for (final change in event.changes) + Text( + '· ' + '${describeChange(change, currency: currency, memberNames: ledger.memberNames)}', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/presentation/widgets/group_avatar_hero.dart b/lib/presentation/widgets/group_avatar_hero.dart new file mode 100644 index 0000000..9eeccd7 --- /dev/null +++ b/lib/presentation/widgets/group_avatar_hero.dart @@ -0,0 +1,41 @@ +import 'package:material_ui/material_ui.dart'; + +import '../../data/local/database.dart'; +import '../../domain/avatar.dart'; +import 'avatar_view.dart'; +import 'shared_element.dart'; + +/// Keeps the selected group's identity visible as its detail page opens. +/// +/// A fitted avatar scales the circle and its contents together during Flutter's +/// stock Hero flight. Deep links need no source hero; reduced motion skips it. +class GroupAvatarHero extends StatelessWidget { + const GroupAvatarHero({ + super.key, + required this.group, + required this.radius, + this.avatar, + }); + + final Group group; + final double radius; + + /// The unsaved picture shown in the group's picture editor. + final Avatar? avatar; + + @override + Widget build(BuildContext context) => SharedElement( + tag: ('group-avatar', group.id), + child: SizedBox.square( + dimension: radius * 2, + child: FittedBox( + child: AvatarView( + avatar: avatar ?? group.avatar, + name: group.name, + id: group.id, + radius: radius, + ), + ), + ), + ); +} diff --git a/lib/presentation/widgets/group_cover.dart b/lib/presentation/widgets/group_cover.dart index 331a2ff..7b35429 100644 --- a/lib/presentation/widgets/group_cover.dart +++ b/lib/presentation/widgets/group_cover.dart @@ -17,20 +17,25 @@ import 'category_icon.dart'; /// categories, so a trip fills with flights and dinners and a flat with bills /// and groceries, and the picture follows the group as it goes on. Before /// anything is spent it uses the group's own avatar. The layout is seeded by -/// the group's id, so the same group looks the same on every device. +/// the group's id, so the same group looks the same on every device. Both the +/// list card and header crop the same fixed composition, including in flight. class GroupCover extends ConsumerWidget { - const GroupCover({super.key, required this.ledger}); + const GroupCover({super.key, required this.ledger, this.patternFade = 0}); final GroupLedger ledger; + /// Softens the pattern under list text: zero in the header, one in a row. + final double patternFade; + /// How many of the most frequent categories make up the pattern. static const _motifs = 5; + static const _artworkSize = Size(640, 192); @override Widget build(BuildContext context, WidgetRef ref) { final group = ledger.group; final avatar = group.avatar; - final scheme = AvatarPalette.of(context)[avatar.color ?? hueFor(group.id)]; + final scheme = AvatarPalette.of(context).forAvatar(avatar, group.id); final categories = { for (final category in ref.watch(categoriesProvider).value ?? const []) @@ -60,11 +65,28 @@ class GroupCover extends ConsumerWidget { child: ColoredBox( color: scheme.primaryContainer, child: IconTheme.merge( - data: IconThemeData(color: scheme.inversePrimary), - child: ClipRect( - child: LayoutBuilder( - builder: (context, constraints) => Stack( - children: _pattern(constraints.biggest, motifs, group.id), + data: IconThemeData( + color: scheme.onPrimaryContainer.withValues(alpha: 0.18), + ), + child: ShaderMask( + blendMode: BlendMode.dstIn, + shaderCallback: (bounds) => LinearGradient( + colors: [ + Colors.black.withValues(alpha: 1 - 0.65 * patternFade), + Colors.black.withValues(alpha: 1 - 0.65 * patternFade), + Colors.black.withValues(alpha: 1 - 0.2 * patternFade), + ], + stops: const [0, 0.6, 1], + ).createShader(bounds), + child: ClipRect( + child: FittedBox( + fit: BoxFit.cover, + child: SizedBox.fromSize( + size: _artworkSize, + child: Stack( + children: _pattern(_artworkSize, motifs, group.id), + ), + ), ), ), ), @@ -84,7 +106,10 @@ class GroupCover extends ConsumerWidget { counts.update(category, (n) => n + 1, ifAbsent: () => 1); } final ranked = counts.keys.toList() - ..sort((a, b) => counts[b]!.compareTo(counts[a]!)); + ..sort((a, b) { + final frequency = counts[b]!.compareTo(counts[a]!); + return frequency == 0 ? a.compareTo(b) : frequency; + }); return [ for (final id in ranked.take(_motifs)) if (categories[id] case final category?) categoryIcon(category.icon), @@ -98,7 +123,7 @@ class GroupCover extends ConsumerWidget { List motifs, String seed, ) { - const cell = 64.0; + const cell = 80.0; final random = Random(stableHash(seed)); final columns = (size.width / cell).ceil() + 1; final rows = (size.height / cell).ceil() + 1; @@ -121,7 +146,7 @@ class GroupCover extends ConsumerWidget { List motifs, Offset origin, ) { - final size = 24.0 + random.nextInt(3) * 8; + final size = 32.0 + random.nextInt(3) * 8; final motif = motifs[random.nextInt(motifs.length)]; return Positioned( left: origin.dx + random.nextDouble() * 16, @@ -133,3 +158,58 @@ class GroupCover extends ConsumerWidget { ); } } + +/// Shares the entire cover, with its thumbnail corners opening into the header. +class GroupCoverHero extends StatelessWidget { + const GroupCoverHero({ + super.key, + required this.ledger, + this.borderRadius = BorderRadius.zero, + this.patternFade = 0, + }); + + final GroupLedger ledger; + final BorderRadius borderRadius; + final double patternFade; + + @override + Widget build(BuildContext context) => HeroMode( + enabled: !MediaQuery.disableAnimationsOf(context), + child: Hero( + tag: ('group-cover', ledger.group.id), + transitionOnUserGestures: true, + flightShuttleBuilder: (context, animation, direction, from, to) { + final source = (from.widget as Hero).child as ClipRRect; + final destination = (to.widget as Hero).child as ClipRRect; + final sourceCover = source.child! as GroupCover; + final destinationCover = destination.child! as GroupCover; + return AnimatedBuilder( + animation: animation, + builder: (context, _) { + final progress = direction == HeroFlightDirection.push + ? animation.value + : 1 - animation.value; + return ClipRRect( + borderRadius: BorderRadiusGeometry.lerp( + source.borderRadius, + destination.borderRadius, + progress, + )!, + child: GroupCover( + ledger: destinationCover.ledger, + patternFade: + sourceCover.patternFade + + (destinationCover.patternFade - sourceCover.patternFade) * + progress, + ), + ); + }, + ); + }, + child: ClipRRect( + borderRadius: borderRadius, + child: GroupCover(ledger: ledger, patternFade: patternFade), + ), + ), + ); +} diff --git a/lib/presentation/widgets/inline_notice.dart b/lib/presentation/widgets/inline_notice.dart new file mode 100644 index 0000000..a0d9ffe --- /dev/null +++ b/lib/presentation/widgets/inline_notice.dart @@ -0,0 +1,77 @@ +import 'package:material_ui/material_ui.dart'; + +/// A contained notice among page content, using the theme's filled card shape. +/// +/// Actions keep their own Material button behaviour and stack when they cannot +/// fit. Reserve [isError] for changes that failed, rather than routine advice. +class InlineNotice extends StatelessWidget { + const InlineNotice({ + super.key, + required this.icon, + required this.title, + required this.message, + required this.actions, + this.isError = false, + }); + + final IconData icon; + final String title; + final String message; + final List actions; + final bool isError; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final foreground = isError ? scheme.onErrorContainer : scheme.onSurface; + + return Card.filled( + color: isError ? scheme.errorContainer : null, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, color: isError ? foreground : scheme.primary), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.titleSmall?.copyWith( + color: foreground, + ), + ), + const SizedBox(height: 4), + Text( + message, + style: theme.textTheme.bodyMedium?.copyWith( + color: isError ? foreground : scheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 12), + OverflowBar( + alignment: MainAxisAlignment.end, + overflowAlignment: OverflowBarAlignment.end, + spacing: 8, + overflowSpacing: 8, + children: actions, + ), + ], + ), + ), + ); + } +} diff --git a/lib/presentation/widgets/link_account_prompt.dart b/lib/presentation/widgets/link_account_prompt.dart index 7eef538..9a0578f 100644 --- a/lib/presentation/widgets/link_account_prompt.dart +++ b/lib/presentation/widgets/link_account_prompt.dart @@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart'; import '../../application/ledger_providers.dart'; import '../../application/session_providers.dart'; +import 'inline_notice.dart'; /// Asks a guest to attach a real account as soon as they are in any group: /// from then on the server holds something only this session can reach, and @@ -26,53 +27,22 @@ class LinkAccountPrompt extends ConsumerWidget { final dismissed = ref.watch(promptDismissedProvider); if (groups.isEmpty || dismissed) return const SizedBox.shrink(); - final scheme = Theme.of(context).colorScheme; - final text = Theme.of(context).textTheme; - return Padding( padding: padding, - child: MaterialBanner( - backgroundColor: scheme.tertiaryContainer, - // Material's own banner padding assumes a single line of content beside - // the icon. - padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), - dividerColor: Colors.transparent, - contentTextStyle: text.bodyMedium?.copyWith( - color: scheme.onTertiaryContainer, - ), - leading: Icon( - Icons.warning_amber_rounded, - color: scheme.onTertiaryContainer, - ), - content: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'Protect access to this guest account', - style: text.titleSmall?.copyWith( - color: scheme.onTertiaryContainer, - ), - ), - const SizedBox(height: 4), - Text( - kIsWeb - ? 'Your groups synchronize, but this browser holds the only ' - 'way back in. Clearing its data can leave you unable to ' - 'reach the guest account.' - : 'Your groups synchronize, but this phone holds the only ' - 'way back in. Losing it or reinstalling can leave you ' - 'unable to reach the guest account.', - ), - ], - ), + child: InlineNotice( + icon: Icons.lock_outline, + title: 'Protect access to this guest account', + message: kIsWeb + ? 'Your groups synchronize, but this browser holds the only ' + 'way back in. Clearing its data can leave you unable to ' + 'reach the guest account.' + : 'Your groups synchronize, but this phone holds the only ' + 'way back in. Losing it or reinstalling can leave you ' + 'unable to reach the guest account.', actions: [ TextButton( onPressed: () => ref.read(promptDismissedProvider.notifier).dismiss(), - style: TextButton.styleFrom( - foregroundColor: scheme.onTertiaryContainer, - ), child: const Text('Not now'), ), FilledButton( diff --git a/lib/presentation/widgets/remainder_field.dart b/lib/presentation/widgets/remainder_field.dart new file mode 100644 index 0000000..13a0ef5 --- /dev/null +++ b/lib/presentation/widgets/remainder_field.dart @@ -0,0 +1,119 @@ +import 'package:flutter/services.dart'; +import 'package:material_ui/material_ui.dart'; + +/// A Material number field that follows derived values until manually edited. +/// +/// Only text selection, composing text and focus live here. Financial values +/// and automatic/manual intent belong to the form. In-progress typing is never +/// overwritten by a recalculation; clearing becomes automatic on focus loss. +class RemainderField extends StatefulWidget { + const RemainderField({ + super.key, + required this.label, + required this.memberName, + required this.onChanged, + required this.inputFormatters, + this.manualText, + this.automaticText, + this.prefixText, + this.suffixText, + this.invalid = false, + }); + + final String label; + final String memberName; + final String? manualText; + final String? automaticText; + final String? prefixText; + final String? suffixText; + final bool invalid; + final ValueChanged onChanged; + final List inputFormatters; + + @override + State createState() => _RemainderFieldState(); +} + +class _RemainderFieldState extends State { + late final _text = TextEditingController(text: _value); + final _focus = FocusNode(); + bool _typing = false; + + String get _value => widget.manualText ?? widget.automaticText ?? ''; + + @override + void initState() { + super.initState(); + _focus.addListener(_focusChanged); + } + + @override + void didUpdateWidget(covariant RemainderField oldWidget) { + super.didUpdateWidget(oldWidget); + // Reset is an explicit action; clearing while typing is different and + // must leave room to type a replacement without the number jumping back. + final reset = + oldWidget.manualText != null && + widget.manualText == null && + _text.text.isNotEmpty; + if (reset) _typing = false; + _refresh(); + } + + void _focusChanged() { + if (!_focus.hasFocus) { + _typing = false; + _refresh(); + } + } + + void _refresh() { + if (_focus.hasFocus && _typing || _text.text == _value) return; + _text.value = TextEditingValue( + text: _value, + selection: TextSelection.collapsed(offset: _value.length), + ); + } + + @override + void dispose() { + _focus.dispose(); + _text.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => Semantics( + hint: widget.manualText == null + ? 'Shares the remainder when other values change.' + : 'Custom value. Stays fixed when other values change.', + child: TextField( + controller: _text, + focusNode: _focus, + selectAllOnFocus: true, + onTapOutside: (_) => _focus.unfocus(), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: widget.inputFormatters, + decoration: InputDecoration( + labelText: widget.label, + hintText: widget.automaticText == null ? '—' : null, + errorText: widget.invalid ? 'Check value' : null, + errorMaxLines: 2, + isDense: true, + prefixText: widget.prefixText, + suffixText: widget.suffixText, + suffixIcon: widget.manualText == null + ? null + : IconButton( + tooltip: "Let ${widget.memberName}'s value adjust", + icon: const Icon(Icons.push_pin_outlined, size: 18), + onPressed: () => widget.onChanged(''), + ), + ), + onChanged: (text) { + _typing = true; + widget.onChanged(text); + }, + ), + ); +} diff --git a/lib/presentation/widgets/segmented_list.dart b/lib/presentation/widgets/segmented_list.dart index e611158..2fcf31c 100644 --- a/lib/presentation/widgets/segmented_list.dart +++ b/lib/presentation/widgets/segmented_list.dart @@ -45,20 +45,23 @@ class Segment extends StatelessWidget { /// The gap between two items of one group. static const gap = 2.0; + /// The same outline for the material surface and its shared cover art. + static BorderRadius radiusAt({required int index, required int count}) => + BorderRadius.vertical( + top: index == 0 ? _outer : _inner, + bottom: index == count - 1 ? _outer : _inner, + ); + @override Widget build(BuildContext context) { final first = index == 0; - final last = index == count - 1; return Padding( padding: EdgeInsets.only(top: first ? 0 : gap), child: Material( color: Theme.of(context).colorScheme.surfaceContainer, clipBehavior: Clip.antiAlias, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.vertical( - top: first ? _outer : _inner, - bottom: last ? _outer : _inner, - ), + borderRadius: radiusAt(index: index, count: count), ), child: child, ), diff --git a/lib/presentation/widgets/shared_element.dart b/lib/presentation/widgets/shared_element.dart new file mode 100644 index 0000000..0545f1e --- /dev/null +++ b/lib/presentation/widgets/shared_element.dart @@ -0,0 +1,120 @@ +import 'package:material_ui/material_ui.dart'; + +/// A visual shared by two routes, with the system's reduced-motion preference. +class SharedElement extends StatelessWidget { + const SharedElement({super.key, required this.tag, required this.child}); + + final Object tag; + final Widget child; + + @override + Widget build(BuildContext context) => HeroMode( + enabled: !MediaQuery.disableAnimationsOf(context), + child: Hero( + tag: tag, + transitionOnUserGestures: true, + child: Material(type: MaterialType.transparency, child: child), + ), + ); +} + +/// Shares a label or value with an editable field without flying its controls. +/// +/// The field stays on its route. Only its text flies, with explicit styles so +/// the overlay cannot inherit a different font, colour or text scale. +class SharedText extends StatelessWidget { + const SharedText({ + super.key, + required this.tag, + required this.text, + required this.style, + this.maxLines = 1, + this.child, + }); + + final Object tag; + final String text; + final TextStyle style; + final int maxLines; + final Widget? child; + + @override + Widget build(BuildContext context) => HeroMode( + enabled: !MediaQuery.disableAnimationsOf(context), + child: Hero( + tag: tag, + transitionOnUserGestures: true, + flightShuttleBuilder: _flight, + child: _TextEndpoint( + text: text, + style: style, + textScaler: MediaQuery.textScalerOf(context), + maxLines: maxLines, + child: child, + ), + ), + ); + + static Widget _flight( + BuildContext context, + Animation animation, + HeroFlightDirection direction, + BuildContext fromContext, + BuildContext toContext, + ) { + final from = (fromContext.widget as Hero).child as _TextEndpoint; + final to = (toContext.widget as Hero).child as _TextEndpoint; + return AnimatedBuilder( + animation: animation, + builder: (context, _) => Material( + type: MaterialType.transparency, + child: Align( + alignment: AlignmentDirectional.centerStart, + child: Text( + to.text, + style: TextStyle.lerp( + from.style, + to.style, + direction == HeroFlightDirection.push + ? animation.value + : 1 - animation.value, + ), + textScaler: to.textScaler, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ); + } +} + +class _TextEndpoint extends StatelessWidget { + const _TextEndpoint({ + required this.text, + required this.style, + required this.textScaler, + required this.maxLines, + this.child, + }); + + final String text; + final TextStyle style; + final TextScaler textScaler; + final int maxLines; + final Widget? child; + + @override + Widget build(BuildContext context) => Material( + type: MaterialType.transparency, + child: + child ?? + Text( + text, + style: style, + textScaler: textScaler, + maxLines: maxLines, + overflow: TextOverflow.ellipsis, + ), + ); +} diff --git a/lib/presentation/widgets/sync_status_notice.dart b/lib/presentation/widgets/sync_status_notice.dart index 6ab0970..ad9989e 100644 --- a/lib/presentation/widgets/sync_status_notice.dart +++ b/lib/presentation/widgets/sync_status_notice.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../application/sync_providers.dart'; import '../launch_hold.dart'; +import 'inline_notice.dart'; /// Stands in, invisibly, for saved content the local database has not /// returned yet. @@ -139,24 +140,17 @@ class _SyncProblem extends StatelessWidget { final VoidCallback? onRetry; @override - Widget build(BuildContext context) => Card.outlined( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 8), - Text(message), - const SizedBox(height: 8), - TextButton.icon( - onPressed: onRetry, - icon: const Icon(Icons.refresh), - label: Text(onRetry == null ? 'Retrying…' : 'Try again'), - ), - ], + Widget build(BuildContext context) => InlineNotice( + icon: Icons.sync_problem_rounded, + title: title, + message: message, + actions: [ + TextButton.icon( + onPressed: onRetry, + icon: const Icon(Icons.refresh), + label: Text(onRetry == null ? 'Retrying…' : 'Try again'), ), - ), + ], ); } diff --git a/lib/presentation/widgets/unsynced_changes_banner.dart b/lib/presentation/widgets/unsynced_changes_banner.dart index 16dd6b1..be89dd9 100644 --- a/lib/presentation/widgets/unsynced_changes_banner.dart +++ b/lib/presentation/widgets/unsynced_changes_banner.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../application/local_providers.dart'; import '../../application/sync_providers.dart'; import '../../data/sync/outbox_queue.dart'; +import 'inline_notice.dart'; /// Says out loud that something recorded on this device never reached anyone. class UnsyncedChangesBanner extends ConsumerWidget { @@ -18,45 +19,22 @@ class UnsyncedChangesBanner extends ConsumerWidget { final failures = ref.watch(failedWritesProvider).value ?? const []; if (failures.isEmpty) return const SizedBox.shrink(); - final scheme = Theme.of(context).colorScheme; - final text = Theme.of(context).textTheme; final count = failures.length; - return Padding( padding: padding, - // Material's banner: "an important, succinct message with actions, - // that requires a user action to dismiss". - child: MaterialBanner( - backgroundColor: scheme.errorContainer, - contentTextStyle: text.bodyMedium?.copyWith( - color: scheme.onErrorContainer, - ), - leading: Icon( - Icons.sync_problem_rounded, - color: scheme.onErrorContainer, - ), - content: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - count == 1 - ? 'One change could not be saved' - : '$count changes could not be saved', - style: text.titleSmall?.copyWith(color: scheme.onErrorContainer), - ), - const SizedBox(height: 4), - Text( - count == 1 - ? '“${failures.single.label}” is on this device only. Nobody ' - 'else in the group can see it, and it is not counted ' - 'in their balances.' - : 'They are on this device only. Nobody else in the group ' - 'can see them, and they are not counted in their ' - 'balances.', - ), - ], - ), + child: InlineNotice( + isError: true, + icon: Icons.sync_problem_rounded, + title: count == 1 + ? 'One change could not be saved' + : '$count changes could not be saved', + message: count == 1 + ? '“${failures.single.label}” is on this device only. Nobody ' + 'else in the group can see it, and it is not counted ' + 'in their balances.' + : 'They are on this device only. Nobody else in the group ' + 'can see them, and they are not counted in their ' + 'balances.', actions: [ TextButton( onPressed: () => _showDetails(context, failures), diff --git a/pubspec.yaml b/pubspec.yaml index 9452139..8f05560 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -7,7 +7,7 @@ publish_to: "none" # never by hand (see "Versions" in docs/runbook.md). There is no `+build` # here on purpose. The release workflow numbers every build itself, and a # number written here would only be a second, stale answer. -version: 3.0.0 +version: 3.1.0 environment: sdk: ^3.12.2 @@ -124,21 +124,21 @@ flutter: # It cares about the name and nothing else. So the name is declared, and it # points at the face this app actually uses. That is not a trick played on the # engine: the entry means "the family to fall back to", the engine spells that - # "Roboto", and answering with Instrument Sans is more nearly correct than + # "Roboto", and answering with Google Sans Flex is more nearly correct than # shipping a second unrelated typeface would be — anything that falls through # lands on the app's own face instead of a stray Roboto. Nothing in this app # asks for "Roboto" by name. # # `assets:` is the separate mechanism package:google_fonts scans, and it is # how the brand faces are found. google_fonts registers them under its own - # family names (InstrumentSans_regular and so on), so the declaration below + # family names (GoogleSansFlex_regular and so on), so the declaration below # cannot collide with them even though it names the same file. With the files # present it resolves from the bundle, and allowRuntimeFetching is off in # main.dart so a packaging miss throws instead of reaching for the network. # # The brand face needs no `fonts:` entry of its own: google_fonts matches it # by filename, and these names are the ones it builds out of its own font - # descriptors. Instrument Sans carries 400 and 500 for Material 3's type scale + # descriptors. Google Sans Flex carries 400 and 500 for Material 3's type scale # plus 600, the one weight this app asks for by hand. See # assets/google_fonts/LICENSE for provenance. assets: @@ -153,7 +153,7 @@ flutter: fonts: - family: Roboto fonts: - - asset: assets/google_fonts/InstrumentSans-Regular.ttf + - asset: assets/google_fonts/GoogleSansFlex-Regular.ttf weight: 400 # cider's links in CHANGELOG.md. The release workflow tags each version as diff --git a/server/src/do/group/ledger.ts b/server/src/do/group/ledger.ts index ee3517c..8d0e7d1 100644 --- a/server/src/do/group/ledger.ts +++ b/server/src/do/group/ledger.ts @@ -59,7 +59,11 @@ export function putEntry(tx: Tx, id: string, input: EntryInput, { now, actor }: if (stored) { if (JSON.stringify(comparable(stored)) === JSON.stringify(comparable(input))) return stored; assertBaseIsCurrent(stored, input); + if (stored.kind !== input.kind) { + refuse("malformed", "An expense cannot become a payment, or a payment an expense."); + } } + assertSettlement(input); assertDepartedOnlySettle(tx, stored, input); const seq = nextSeq(tx); @@ -108,6 +112,14 @@ function assertBalanced(input: EntryInput): void { } } +/** A live payment transfers the whole amount between two different people. */ +function assertSettlement(input: EntryInput): void { + if (input.kind !== "settlement" || input.deletedAt !== null) return; + if (input.payers.length !== 1 || input.shares.length !== 1 || input.payers[0]?.memberId === input.shares[0]?.memberId) { + refuse("malformed", "A payment needs one sender and one different recipient."); + } +} + function assertMembersExist(tx: Tx, input: EntryInput): void { if (new Set(input.payers.map((payer) => payer.memberId)).size !== input.payers.length) { refuse("malformed", "The same person is listed twice as a payer."); diff --git a/server/src/email/sign-in-code.ts b/server/src/email/sign-in-code.ts index 47009eb..ce1615b 100644 --- a/server/src/email/sign-in-code.ts +++ b/server/src/email/sign-in-code.ts @@ -5,7 +5,7 @@ import type { EmailMessage } from "./sender"; * * Mail clients are not browsers: Gmail drops @@ -65,11 +65,11 @@ function html(code: string): string {
- diff --git a/server/test/ledger.test.ts b/server/test/ledger.test.ts index c75ecbc..63ab0a6 100644 --- a/server/test/ledger.test.ts +++ b/server/test/ledger.test.ts @@ -2,6 +2,88 @@ import { describe, expect, it } from "vitest"; import { deleteEntry, draftOf, editGroup, editMember, evenly, expense, freshId, makeGroup, ok, PRIYA, RAVI, refusal, restoreEntry, saveEntry, stub, sumOf } from "./group"; +describe("a recorded payment", () => { + it("updates the amount and note while remaining a settlement", async () => { + const { groupId, ravi, priya } = await makeGroup(); + const object = stub(groupId); + const first = ok( + await saveEntry( + object, + expense({ + id: freshId("e"), + kind: "settlement", + amountMinor: 1200, + splitKind: "exact", + payers: [{ memberId: priya.id, amountMinor: 1200 }], + shares: [{ memberId: ravi.id, amountMinor: 1200, weightMicros: null }], + notes: "Paid in cash", + }), + RAVI, + ), + ); + const updated = ok( + await saveEntry( + object, + draftOf(first, { + amountMinor: 900, + notes: "Bank transfer", + payers: [{ memberId: priya.id, amountMinor: 900 }], + shares: [{ memberId: ravi.id, amountMinor: 900, weightMicros: null }], + }), + RAVI, + ), + ); + expect(updated.kind).toBe("settlement"); + expect(updated.amountMinor).toBe(900); + expect(updated.notes).toBe("Bank transfer"); + expect(updated.createdAt).toBe(first.createdAt); + expect(updated.createdBy).toBe(first.createdBy); + }); + + it.each(["expense", "settlement"] as const)("refuses to reclassify an existing %s", async (kind) => { + const { groupId, ravi, priya } = await makeGroup(); + const object = stub(groupId); + const first = ok( + await saveEntry( + object, + expense({ + id: freshId("e"), + kind, + amountMinor: 500, + payers: [{ memberId: priya.id, amountMinor: 500 }], + shares: [{ memberId: ravi.id, amountMinor: 500, weightMicros: null }], + }), + RAVI, + ), + ); + const changed = draftOf(first, { kind: kind === "expense" ? "settlement" : "expense" }); + expect(refusal(await saveEntry(object, changed, RAVI)).code).toBe("malformed"); + const page = ok(await object.changes(RAVI, 0, 500)); + expect(page.entries.find((row) => row.id === first.id)?.kind).toBe(kind); + }); + + it.each(["self", "multiple senders", "multiple recipients"] as const)("refuses a balanced payment with %s", async (shape) => { + const { groupId, ravi, priya } = await makeGroup(); + const payers = + shape === "multiple senders" + ? [ + { memberId: ravi.id, amountMinor: 250 }, + { memberId: priya.id, amountMinor: 250 }, + ] + : [{ memberId: priya.id, amountMinor: 500 }]; + const shares = + shape === "multiple recipients" + ? [ + { memberId: ravi.id, amountMinor: 250, weightMicros: null }, + { memberId: priya.id, amountMinor: 250, weightMicros: null }, + ] + : [{ memberId: shape === "self" ? priya.id : ravi.id, amountMinor: 500, weightMicros: null }]; + const result = await saveEntry(stub(groupId), expense({ id: freshId("e"), kind: "settlement", amountMinor: 500, payers, shares }), RAVI); + expect(refusal(result).code).toBe("malformed"); + expect(ok(await stub(groupId).changes(RAVI, 0, 500)).entries).toHaveLength(0); + }); +}); + /** The invariant and the write path: an expense that does not add up, a hard delete, an idempotent retry, and the stale-base rule. */ describe("an expense has to add up", () => { it("accepts one that does", async () => { diff --git a/site/404.html b/site/404.html index 009a72b..2c0b14f 100644 --- a/site/404.html +++ b/site/404.html @@ -22,7 +22,7 @@ Page not found · OpenSplit - + @@ -52,7 +52,7 @@
+
OpenSplit

Your code

Enter it in OpenSplit to carry on.

-
${code}
+
${code}

It expires in ten minutes and can be used once.

If you did not ask for this, you can ignore this message — nothing has changed on your account.