Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d0a75882d | ||
| 7c57b7bcbb | |||
| c5595c0d1a | |||
| ea8ff9aabd | |||
| e6284b9577 | |||
|
|
2545e421de | ||
| a5c8e5b479 | |||
| e69625e8af | |||
|
|
a656f73bf0 | ||
| 5ae3afcd41 | |||
| 4d0c28f263 | |||
| 3b897982d6 |
1
.flutter-version
Normal file
@@ -0,0 +1 @@
|
||||
3.41.4
|
||||
6
.gitignore
vendored
@@ -54,7 +54,7 @@ android/app/*.jks
|
||||
android/app/*.keystore
|
||||
|
||||
# Fastlane
|
||||
android/fastlane/play-store-key.json
|
||||
ios/fastlane/report.xml
|
||||
android/fastlane/report.xml
|
||||
fastlane/play-store-key.json
|
||||
fastlane/.image_hashes.json
|
||||
fastlane/report.xml
|
||||
/.envrc
|
||||
|
||||
33
CHANGELOG.md
@@ -1,5 +1,38 @@
|
||||
# Changelog
|
||||
|
||||
## [0.3.0](https://github.com/chenasraf/pantry-flutter/compare/v0.2.1...v0.3.0) (2026-04-12)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* improve main page navigations ([ea8ff9a](https://github.com/chenasraf/pantry-flutter/commit/ea8ff9aabd0924f8273927c907b800576c7cd697))
|
||||
* move items between lists ([c5595c0](https://github.com/chenasraf/pantry-flutter/commit/c5595c0d1ae07c3c2dbf35fba5a70a957fc9af17))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* support back button when in photos foldeer ([e6284b9](https://github.com/chenasraf/pantry-flutter/commit/e6284b95774a5bd967af95626acb6ec3562ae9a5))
|
||||
|
||||
## [0.2.1](https://github.com/chenasraf/pantry-flutter/compare/v0.2.0...v0.2.1) (2026-04-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* sorting prefs persistence & error wrapping ([a5c8e5b](https://github.com/chenasraf/pantry-flutter/commit/a5c8e5b479e92f87ea910b5af19ca24711ce7b16))
|
||||
|
||||
## [0.2.0](https://github.com/chenasraf/pantry-flutter/compare/v0.1.0...v0.2.0) (2026-04-11)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add sorting by category for checklist ([5ae3afc](https://github.com/chenasraf/pantry-flutter/commit/5ae3afcd41628db0d1a758602e1a6fae652788ae))
|
||||
* notifications support ([4d0c28f](https://github.com/chenasraf/pantry-flutter/commit/4d0c28f2633c75647b41e765fa02935386798034))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add bottom padding to accomodate fab ([3b89798](https://github.com/chenasraf/pantry-flutter/commit/3b897982d60f71d0cadd5ac21386929aad10851e))
|
||||
|
||||
## 0.1.0 (2026-04-10)
|
||||
|
||||
|
||||
|
||||
10
Makefile
@@ -207,10 +207,10 @@ ios-release: ios-build
|
||||
|
||||
.PHONY: android-upload
|
||||
android-upload:
|
||||
@echo "$(or $(TRACK),internal)" | grep -qE '^(internal|alpha|beta|production)$$' || (echo "Error: Invalid TRACK '$(TRACK)'. Must be: internal, alpha, beta, production"; exit 1)
|
||||
@echo "$(or $(TRACK),beta)" | grep -qE '^(internal|alpha|beta|production)$$' || (echo "Error: Invalid TRACK '$(TRACK)'. Must be: internal, alpha, beta, production"; exit 1)
|
||||
@echo "$(or $(STATUS),draft)" | grep -qE '^(draft|completed|halted|inProgress)$$' || (echo "Error: Invalid STATUS '$(STATUS)'. Must be: draft, completed, halted, inProgress"; exit 1)
|
||||
@echo "Track: $(or $(TRACK),internal) | Status: $(or $(STATUS),draft)"
|
||||
cd android && bundle exec fastlane deploy track:$(or $(TRACK),internal) status:$(or $(STATUS),draft)
|
||||
bundle exec fastlane deploy track:$(or $(TRACK),internal) status:$(or $(STATUS),draft)
|
||||
|
||||
.PHONY: android-deploy
|
||||
android-deploy: android-build-aab android-upload
|
||||
@@ -221,16 +221,16 @@ android-promote:
|
||||
@echo "$(or $(TO),production)" | grep -qE '^(internal|alpha|beta|production)$$' || (echo "Error: Invalid TO '$(TO)'. Must be: internal, alpha, beta, production"; exit 1)
|
||||
@echo "$(or $(STATUS),draft)" | grep -qE '^(draft|completed|halted|inProgress)$$' || (echo "Error: Invalid STATUS '$(STATUS)'. Must be: draft, completed, halted, inProgress"; exit 1)
|
||||
@echo "Promote: $(or $(FROM),internal) -> $(or $(TO),production) | Status: $(or $(STATUS),draft)"
|
||||
cd android && bundle exec fastlane promote from:$(or $(FROM),internal) to:$(or $(TO),production) status:$(or $(STATUS),draft)
|
||||
bundle exec fastlane promote from:$(or $(FROM),internal) to:$(or $(TO),production) status:$(or $(STATUS),draft)
|
||||
|
||||
.PHONY: ios-upload
|
||||
ios-upload:
|
||||
@echo "$(or $(DEST),testflight)" | grep -qE '^(testflight|appstore)$$' || (echo "Error: Invalid DEST '$(DEST)'. Must be: testflight, appstore"; exit 1)
|
||||
@echo "Destination: $(or $(DEST),testflight)"
|
||||
@if [ "$(or $(DEST),testflight)" = "appstore" ]; then \
|
||||
cd ios && bundle exec fastlane release; \
|
||||
bundle exec fastlane ios release; \
|
||||
else \
|
||||
cd ios && bundle exec fastlane beta; \
|
||||
bundle exec fastlane ios beta; \
|
||||
fi
|
||||
|
||||
.PHONY: ios-deploy
|
||||
|
||||
@@ -22,6 +22,7 @@ android {
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
@@ -60,3 +61,7 @@ android {
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
|
||||
dependencies {
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||
<application
|
||||
android:label="Pantry"
|
||||
android:name="${applicationName}"
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
json_key_file(ENV["GOOGLE_PLAY_KEY_FILE"])
|
||||
package_name("dev.casraf.pantry")
|
||||
@@ -1,82 +0,0 @@
|
||||
default_platform(:android)
|
||||
|
||||
platform :android do
|
||||
def version_info
|
||||
pubspec = File.read(File.expand_path("../../pubspec.yaml", __dir__))
|
||||
version = pubspec.match(/^version:\s*(.+)$/)[1].strip
|
||||
name, build = version.split("+")
|
||||
{ name: name, build: build }
|
||||
end
|
||||
|
||||
def version_name
|
||||
v = version_info
|
||||
"#{v[:build]} (#{v[:name]})"
|
||||
end
|
||||
|
||||
def changelog_notes
|
||||
version = version_info[:name]
|
||||
changelog_path = File.expand_path("../../CHANGELOG.md", __dir__)
|
||||
return "Release #{version}" unless File.exist?(changelog_path)
|
||||
|
||||
changelog = File.read(changelog_path)
|
||||
pattern = /^## \[#{Regexp.escape(version)}\].*?\n(.*?)(?=^## \[|\z)/m
|
||||
match = changelog.match(pattern)
|
||||
return "Release #{version}" unless match
|
||||
|
||||
match[1].strip
|
||||
.gsub(/\s*\(\[[\da-f]+\]\([^)]+\)\)/, "")
|
||||
.gsub(/^\*\s+\*\*[^*]+:\*\*\s*/m, "- ")
|
||||
.gsub(/^\*\s+/, "- ")
|
||||
.gsub(/^### /, "")
|
||||
.gsub(/\n{3,}/, "\n\n")
|
||||
.strip
|
||||
.then { |n| n.empty? ? "Release #{version}" : n }
|
||||
end
|
||||
|
||||
desc "Upload AAB to Google Play"
|
||||
lane :deploy do |options|
|
||||
# Write release notes to the metadata tree so `supply` picks it up
|
||||
# keyed by the current versionCode.
|
||||
version_code = version_info[:build]
|
||||
changelog_dir = File.expand_path("metadata/android/en-US/changelogs", __dir__)
|
||||
FileUtils.mkdir_p(changelog_dir)
|
||||
File.write(File.join(changelog_dir, "#{version_code}.txt"), changelog_notes)
|
||||
|
||||
upload_to_play_store(
|
||||
aab: File.expand_path("../../build/app/outputs/bundle/release/app-release.aab", __dir__),
|
||||
track: options[:track] || "internal",
|
||||
release_status: options[:status] || "draft",
|
||||
version_name: version_name,
|
||||
metadata_path: File.expand_path("metadata/android", __dir__),
|
||||
)
|
||||
end
|
||||
|
||||
desc "Sync metadata and screenshots only (no AAB upload)"
|
||||
lane :metadata do
|
||||
upload_to_play_store(
|
||||
skip_upload_aab: true,
|
||||
skip_upload_apk: true,
|
||||
metadata_path: File.expand_path("metadata/android", __dir__),
|
||||
)
|
||||
end
|
||||
|
||||
desc "Promote a release from one track to another"
|
||||
lane :promote do |options|
|
||||
from = options[:from] || "internal"
|
||||
to = options[:to] || "production"
|
||||
status = options[:status] || "draft"
|
||||
|
||||
upload_to_play_store(
|
||||
track: from,
|
||||
track_promote_to: to,
|
||||
release_status: status,
|
||||
version_name: version_name,
|
||||
skip_upload_aab: true,
|
||||
skip_upload_apk: true,
|
||||
skip_upload_metadata: true,
|
||||
skip_upload_changelogs: true,
|
||||
skip_upload_images: true,
|
||||
skip_upload_screenshots: true,
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,9 @@
|
||||
# Android
|
||||
json_key_file(ENV["GOOGLE_PLAY_KEY_FILE"])
|
||||
package_name("dev.casraf.pantry")
|
||||
|
||||
# iOS
|
||||
app_identifier("dev.casraf.pantry")
|
||||
apple_id(ENV["APPLE_ID"])
|
||||
|
||||
itc_team_id(ENV["ITC_TEAM_ID"])
|
||||
team_id(ENV["APPLE_TEAM_ID"])
|
||||
188
fastlane/Fastfile
Normal file
@@ -0,0 +1,188 @@
|
||||
require "digest"
|
||||
require "json"
|
||||
|
||||
# -- Shared helpers --
|
||||
|
||||
def version_info
|
||||
pubspec = File.read(File.expand_path("../pubspec.yaml", __dir__))
|
||||
version = pubspec.match(/^version:\s*(.+)$/)[1].strip
|
||||
name, build = version.split("+")
|
||||
{ name: name, build: build }
|
||||
end
|
||||
|
||||
def version_name
|
||||
v = version_info
|
||||
"#{v[:build]} (#{v[:name]})"
|
||||
end
|
||||
|
||||
def changelog_notes
|
||||
version = version_info[:name]
|
||||
changelog_path = File.expand_path("../CHANGELOG.md", __dir__)
|
||||
return "Release #{version}" unless File.exist?(changelog_path)
|
||||
|
||||
changelog = File.read(changelog_path)
|
||||
escaped = Regexp.escape(version)
|
||||
pattern = /^## (?:\[#{escaped}\]|#{escaped}[\s(]).*?\n(.*?)(?=^## |\z)/m
|
||||
match = changelog.match(pattern)
|
||||
return "Release #{version}" unless match
|
||||
|
||||
match[1].strip
|
||||
.gsub(/\s*\(\[[\da-f]+\]\([^)]+\)\)/, "")
|
||||
.gsub(/^\*\s+\*\*[^*]+:\*\*\s*/m, "- ")
|
||||
.gsub(/^\*\s+/, "- ")
|
||||
.gsub(/^### /, "")
|
||||
.gsub(/\n{3,}/, "\n\n")
|
||||
.strip
|
||||
.then { |n| n.empty? ? "Release #{version}" : n }
|
||||
end
|
||||
|
||||
# -- Android --
|
||||
|
||||
default_platform(:android)
|
||||
|
||||
platform :android do
|
||||
# Google Play enforces a 500-char limit on release notes per language.
|
||||
PLAY_NOTES_MAX = 500
|
||||
PLAY_NOTES_TRAILER = "\n\n… see full notes on GitHub."
|
||||
|
||||
def play_changelog
|
||||
notes = changelog_notes
|
||||
return notes if notes.length <= PLAY_NOTES_MAX
|
||||
|
||||
budget = PLAY_NOTES_MAX - PLAY_NOTES_TRAILER.length
|
||||
truncated = notes[0, budget]
|
||||
last_newline = truncated.rindex("\n")
|
||||
truncated = truncated[0, last_newline] if last_newline && last_newline > budget / 2
|
||||
truncated.rstrip + PLAY_NOTES_TRAILER
|
||||
end
|
||||
|
||||
# -- Image change detection --
|
||||
|
||||
IMAGE_HASH_CACHE = File.expand_path(".image_hashes.json", __dir__)
|
||||
|
||||
def current_image_hashes
|
||||
root = File.expand_path("metadata/android", __dir__)
|
||||
return {} unless Dir.exist?(root)
|
||||
|
||||
files = Dir.glob(File.join(root, "**/images/**/*")).select { |f| File.file?(f) }
|
||||
files.sort.each_with_object({}) do |path, acc|
|
||||
rel = path.sub("#{root}/", "")
|
||||
acc[rel] = Digest::SHA256.file(path).hexdigest
|
||||
end
|
||||
end
|
||||
|
||||
def cached_image_hashes
|
||||
return {} unless File.exist?(IMAGE_HASH_CACHE)
|
||||
JSON.parse(File.read(IMAGE_HASH_CACHE))
|
||||
rescue StandardError
|
||||
{}
|
||||
end
|
||||
|
||||
def images_changed?
|
||||
current_image_hashes != cached_image_hashes
|
||||
end
|
||||
|
||||
def save_image_hashes
|
||||
File.write(IMAGE_HASH_CACHE, JSON.pretty_generate(current_image_hashes))
|
||||
end
|
||||
|
||||
desc "Upload AAB to Google Play"
|
||||
lane :deploy do |options|
|
||||
version_code = version_info[:build]
|
||||
changelog_dir = File.expand_path("metadata/android/en-US/changelogs", __dir__)
|
||||
FileUtils.mkdir_p(changelog_dir)
|
||||
File.write(File.join(changelog_dir, "#{version_code}.txt"), play_changelog)
|
||||
|
||||
changed = images_changed?
|
||||
UI.message(changed ? "Images changed — uploading." : "Images unchanged — skipping.")
|
||||
|
||||
upload_to_play_store(
|
||||
aab: File.expand_path("../build/app/outputs/bundle/release/app-release.aab", __dir__),
|
||||
track: options[:track] || "internal",
|
||||
release_status: options[:status] || "draft",
|
||||
version_name: version_name,
|
||||
metadata_path: File.expand_path("metadata/android", __dir__),
|
||||
skip_upload_images: !changed,
|
||||
skip_upload_screenshots: !changed,
|
||||
)
|
||||
|
||||
save_image_hashes if changed
|
||||
end
|
||||
|
||||
desc "Sync metadata and screenshots only (no AAB upload)"
|
||||
lane :metadata do
|
||||
changed = images_changed?
|
||||
UI.message(changed ? "Images changed — uploading." : "Images unchanged — skipping.")
|
||||
|
||||
upload_to_play_store(
|
||||
skip_upload_aab: true,
|
||||
skip_upload_apk: true,
|
||||
metadata_path: File.expand_path("metadata/android", __dir__),
|
||||
skip_upload_images: !changed,
|
||||
skip_upload_screenshots: !changed,
|
||||
)
|
||||
|
||||
save_image_hashes if changed
|
||||
end
|
||||
|
||||
desc "Promote a release from one track to another"
|
||||
lane :promote do |options|
|
||||
from = options[:from] || "internal"
|
||||
to = options[:to] || "production"
|
||||
status = options[:status] || "draft"
|
||||
|
||||
upload_to_play_store(
|
||||
track: from,
|
||||
track_promote_to: to,
|
||||
release_status: status,
|
||||
version_name: version_name,
|
||||
skip_upload_aab: true,
|
||||
skip_upload_apk: true,
|
||||
skip_upload_metadata: true,
|
||||
skip_upload_changelogs: true,
|
||||
skip_upload_images: true,
|
||||
skip_upload_screenshots: true,
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
# -- iOS --
|
||||
|
||||
platform :ios do
|
||||
def api_key
|
||||
key_id = ENV.fetch("APP_STORE_API_KEY")
|
||||
app_store_connect_api_key(
|
||||
key_id: key_id,
|
||||
issuer_id: ENV.fetch("APP_STORE_ISSUER_ID"),
|
||||
key_filepath: File.join(ENV.fetch("APP_STORE_KEY_PATH"), "AuthKey_#{key_id}.p8"),
|
||||
)
|
||||
end
|
||||
|
||||
def find_ipa
|
||||
ipa_path = Dir[File.expand_path("../build/ios/ipa/*.ipa", __dir__)].first
|
||||
UI.user_error!("No IPA found in build/ios/ipa/. Run 'make ios-build' first.") unless ipa_path
|
||||
ipa_path
|
||||
end
|
||||
|
||||
desc "Upload to TestFlight"
|
||||
lane :beta do
|
||||
upload_to_testflight(
|
||||
api_key: api_key,
|
||||
ipa: find_ipa,
|
||||
changelog: changelog_notes,
|
||||
skip_waiting_for_build_processing: true,
|
||||
)
|
||||
end
|
||||
|
||||
desc "Upload to App Store"
|
||||
lane :release do
|
||||
deliver(
|
||||
api_key: api_key,
|
||||
ipa: find_ipa,
|
||||
skip_metadata: true,
|
||||
skip_screenshots: true,
|
||||
submit_for_review: false,
|
||||
release_notes: { "en-US" => changelog_notes },
|
||||
)
|
||||
end
|
||||
end
|
||||
48
fastlane/README.md
Normal file
@@ -0,0 +1,48 @@
|
||||
fastlane documentation
|
||||
----
|
||||
|
||||
# Installation
|
||||
|
||||
Make sure you have the latest version of the Xcode command line tools installed:
|
||||
|
||||
```sh
|
||||
xcode-select --install
|
||||
```
|
||||
|
||||
For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane)
|
||||
|
||||
# Available Actions
|
||||
|
||||
## Android
|
||||
|
||||
### android deploy
|
||||
|
||||
```sh
|
||||
[bundle exec] fastlane android deploy
|
||||
```
|
||||
|
||||
Upload AAB to Google Play
|
||||
|
||||
### android metadata
|
||||
|
||||
```sh
|
||||
[bundle exec] fastlane android metadata
|
||||
```
|
||||
|
||||
Sync metadata and screenshots only (no AAB upload)
|
||||
|
||||
### android promote
|
||||
|
||||
```sh
|
||||
[bundle exec] fastlane android promote
|
||||
```
|
||||
|
||||
Promote a release from one track to another
|
||||
|
||||
----
|
||||
|
||||
This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run.
|
||||
|
||||
More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools).
|
||||
|
||||
The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools).
|
||||
1
fastlane/metadata/android/en-US/changelogs/2.txt
Normal file
@@ -0,0 +1 @@
|
||||
Release 0.1.0
|
||||
8
fastlane/metadata/android/en-US/changelogs/3.txt
Normal file
@@ -0,0 +1,8 @@
|
||||
Features
|
||||
|
||||
- add sorting by category for checklist
|
||||
- notifications support
|
||||
|
||||
Bug Fixes
|
||||
|
||||
- add bottom padding to accomodate fab
|
||||
3
fastlane/metadata/android/en-US/changelogs/4.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
Bug Fixes
|
||||
|
||||
- sorting prefs persistence & error wrapping
|
||||
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 517 KiB After Width: | Height: | Size: 517 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 400 KiB After Width: | Height: | Size: 400 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 455 KiB After Width: | Height: | Size: 455 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 99 KiB |
@@ -1,3 +0,0 @@
|
||||
source "https://rubygems.org"
|
||||
|
||||
gem "fastlane"
|
||||
338
ios/Gemfile.lock
@@ -1,338 +0,0 @@
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
CFPropertyList (3.0.8)
|
||||
abbrev (0.1.2)
|
||||
addressable (2.9.0)
|
||||
public_suffix (>= 2.0.2, < 8.0)
|
||||
artifactory (3.0.17)
|
||||
atomos (0.1.3)
|
||||
aws-eventstream (1.4.0)
|
||||
aws-partitions (1.1237.0)
|
||||
aws-sdk-core (3.244.0)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
base64
|
||||
bigdecimal
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
logger
|
||||
aws-sdk-kms (1.123.0)
|
||||
aws-sdk-core (~> 3, >= 3.244.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.219.0)
|
||||
aws-sdk-core (~> 3, >= 3.244.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.12.1)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
babosa (1.0.4)
|
||||
base64 (0.2.0)
|
||||
benchmark (0.5.0)
|
||||
bigdecimal (4.1.1)
|
||||
claide (1.1.0)
|
||||
colored (1.2)
|
||||
colored2 (3.1.2)
|
||||
commander (4.6.0)
|
||||
highline (~> 2.0.0)
|
||||
csv (3.3.5)
|
||||
declarative (0.0.20)
|
||||
digest-crc (0.7.0)
|
||||
rake (>= 12.0.0, < 14.0.0)
|
||||
domain_name (0.6.20240107)
|
||||
dotenv (2.8.1)
|
||||
emoji_regex (3.2.3)
|
||||
excon (0.112.0)
|
||||
faraday (1.10.5)
|
||||
faraday-em_http (~> 1.0)
|
||||
faraday-em_synchrony (~> 1.0)
|
||||
faraday-excon (~> 1.1)
|
||||
faraday-httpclient (~> 1.0)
|
||||
faraday-multipart (~> 1.0)
|
||||
faraday-net_http (~> 1.0)
|
||||
faraday-net_http_persistent (~> 1.0)
|
||||
faraday-patron (~> 1.0)
|
||||
faraday-rack (~> 1.0)
|
||||
faraday-retry (~> 1.0)
|
||||
ruby2_keywords (>= 0.0.4)
|
||||
faraday-cookie_jar (0.0.8)
|
||||
faraday (>= 0.8.0)
|
||||
http-cookie (>= 1.0.0)
|
||||
faraday-em_http (1.0.0)
|
||||
faraday-em_synchrony (1.0.1)
|
||||
faraday-excon (1.1.0)
|
||||
faraday-httpclient (1.0.1)
|
||||
faraday-multipart (1.2.0)
|
||||
multipart-post (~> 2.0)
|
||||
faraday-net_http (1.0.2)
|
||||
faraday-net_http_persistent (1.2.0)
|
||||
faraday-patron (1.0.0)
|
||||
faraday-rack (1.0.0)
|
||||
faraday-retry (1.0.4)
|
||||
faraday_middleware (1.2.1)
|
||||
faraday (~> 1.0)
|
||||
fastimage (2.4.1)
|
||||
fastlane (2.232.2)
|
||||
CFPropertyList (>= 2.3, < 4.0.0)
|
||||
abbrev (~> 0.1.2)
|
||||
addressable (>= 2.8, < 3.0.0)
|
||||
artifactory (~> 3.0)
|
||||
aws-sdk-s3 (~> 1.197)
|
||||
babosa (>= 1.0.3, < 2.0.0)
|
||||
base64 (~> 0.2.0)
|
||||
benchmark (>= 0.1.0)
|
||||
bundler (>= 1.17.3, < 5.0.0)
|
||||
colored (~> 1.2)
|
||||
commander (~> 4.6)
|
||||
csv (~> 3.3)
|
||||
dotenv (>= 2.1.1, < 3.0.0)
|
||||
emoji_regex (>= 0.1, < 4.0)
|
||||
excon (>= 0.71.0, < 1.0.0)
|
||||
faraday (~> 1.0)
|
||||
faraday-cookie_jar (~> 0.0.6)
|
||||
faraday_middleware (~> 1.0)
|
||||
fastimage (>= 2.1.0, < 3.0.0)
|
||||
fastlane-sirp (>= 1.0.0)
|
||||
gh_inspector (>= 1.1.2, < 2.0.0)
|
||||
google-apis-androidpublisher_v3 (~> 0.3)
|
||||
google-apis-playcustomapp_v1 (~> 0.1)
|
||||
google-cloud-env (>= 1.6.0, <= 2.1.1)
|
||||
google-cloud-storage (~> 1.31)
|
||||
highline (~> 2.0)
|
||||
http-cookie (~> 1.0.5)
|
||||
json (< 3.0.0)
|
||||
jwt (>= 2.1.0, < 3)
|
||||
logger (>= 1.6, < 2.0)
|
||||
mini_magick (>= 4.9.4, < 5.0.0)
|
||||
multipart-post (>= 2.0.0, < 3.0.0)
|
||||
mutex_m (~> 0.3.0)
|
||||
naturally (~> 2.2)
|
||||
nkf (~> 0.2.0)
|
||||
optparse (>= 0.1.1, < 1.0.0)
|
||||
ostruct (>= 0.1.0)
|
||||
plist (>= 3.1.0, < 4.0.0)
|
||||
rubyzip (>= 2.0.0, < 3.0.0)
|
||||
security (= 0.1.5)
|
||||
simctl (~> 1.6.3)
|
||||
terminal-notifier (>= 2.0.0, < 3.0.0)
|
||||
terminal-table (~> 3)
|
||||
tty-screen (>= 0.6.3, < 1.0.0)
|
||||
tty-spinner (>= 0.8.0, < 1.0.0)
|
||||
word_wrap (~> 1.0.0)
|
||||
xcodeproj (>= 1.13.0, < 2.0.0)
|
||||
xcpretty (~> 0.4.1)
|
||||
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
|
||||
fastlane-sirp (1.0.0)
|
||||
sysrandom (~> 1.0)
|
||||
gh_inspector (1.1.3)
|
||||
google-apis-androidpublisher_v3 (0.98.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-core (0.18.0)
|
||||
addressable (~> 2.5, >= 2.5.1)
|
||||
googleauth (~> 1.9)
|
||||
httpclient (>= 2.8.3, < 3.a)
|
||||
mini_mime (~> 1.0)
|
||||
mutex_m
|
||||
representable (~> 3.0)
|
||||
retriable (>= 2.0, < 4.a)
|
||||
google-apis-iamcredentials_v1 (0.26.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-playcustomapp_v1 (0.17.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-storage_v1 (0.61.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-cloud-core (1.8.0)
|
||||
google-cloud-env (>= 1.0, < 3.a)
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-cloud-env (2.1.1)
|
||||
faraday (>= 1.0, < 3.a)
|
||||
google-cloud-errors (1.6.0)
|
||||
google-cloud-storage (1.59.0)
|
||||
addressable (~> 2.8)
|
||||
digest-crc (~> 0.4)
|
||||
google-apis-core (>= 0.18, < 2)
|
||||
google-apis-iamcredentials_v1 (~> 0.18)
|
||||
google-apis-storage_v1 (>= 0.42)
|
||||
google-cloud-core (~> 1.6)
|
||||
googleauth (~> 1.9)
|
||||
mini_mime (~> 1.0)
|
||||
googleauth (1.11.2)
|
||||
faraday (>= 1.0, < 3.a)
|
||||
google-cloud-env (~> 2.1)
|
||||
jwt (>= 1.4, < 3.0)
|
||||
multi_json (~> 1.11)
|
||||
os (>= 0.9, < 2.0)
|
||||
signet (>= 0.16, < 2.a)
|
||||
highline (2.0.3)
|
||||
http-cookie (1.0.8)
|
||||
domain_name (~> 0.5)
|
||||
httpclient (2.9.0)
|
||||
mutex_m
|
||||
jmespath (1.6.2)
|
||||
json (2.19.3)
|
||||
jwt (2.10.2)
|
||||
base64
|
||||
logger (1.7.0)
|
||||
mini_magick (4.13.2)
|
||||
mini_mime (1.1.5)
|
||||
multi_json (1.19.1)
|
||||
multipart-post (2.4.1)
|
||||
mutex_m (0.3.0)
|
||||
nanaimo (0.4.0)
|
||||
naturally (2.3.0)
|
||||
nkf (0.2.0)
|
||||
optparse (0.8.1)
|
||||
os (1.1.4)
|
||||
ostruct (0.6.3)
|
||||
plist (3.7.2)
|
||||
public_suffix (7.0.5)
|
||||
rake (13.3.1)
|
||||
representable (3.2.0)
|
||||
declarative (< 0.1.0)
|
||||
trailblazer-option (>= 0.1.1, < 0.2.0)
|
||||
uber (< 0.2.0)
|
||||
retriable (3.4.1)
|
||||
rexml (3.4.4)
|
||||
rouge (3.28.0)
|
||||
ruby2_keywords (0.0.5)
|
||||
rubyzip (2.4.1)
|
||||
security (0.1.5)
|
||||
signet (0.21.0)
|
||||
addressable (~> 2.8)
|
||||
faraday (>= 0.17.5, < 3.a)
|
||||
jwt (>= 1.5, < 4.0)
|
||||
multi_json (~> 1.10)
|
||||
simctl (1.6.10)
|
||||
CFPropertyList
|
||||
naturally
|
||||
sysrandom (1.0.5)
|
||||
terminal-notifier (2.0.0)
|
||||
terminal-table (3.0.2)
|
||||
unicode-display_width (>= 1.1.1, < 3)
|
||||
trailblazer-option (0.1.2)
|
||||
tty-cursor (0.7.1)
|
||||
tty-screen (0.8.2)
|
||||
tty-spinner (0.9.3)
|
||||
tty-cursor (~> 0.7)
|
||||
uber (0.1.0)
|
||||
unicode-display_width (2.6.0)
|
||||
word_wrap (1.0.0)
|
||||
xcodeproj (1.27.0)
|
||||
CFPropertyList (>= 2.3.3, < 4.0)
|
||||
atomos (~> 0.1.3)
|
||||
claide (>= 1.0.2, < 2.0)
|
||||
colored2 (~> 3.1)
|
||||
nanaimo (~> 0.4.0)
|
||||
rexml (>= 3.3.6, < 4.0)
|
||||
xcpretty (0.4.1)
|
||||
rouge (~> 3.28.0)
|
||||
xcpretty-travis-formatter (1.0.1)
|
||||
xcpretty (~> 0.2, >= 0.0.7)
|
||||
|
||||
PLATFORMS
|
||||
arm64-darwin-24
|
||||
ruby
|
||||
|
||||
DEPENDENCIES
|
||||
fastlane
|
||||
|
||||
CHECKSUMS
|
||||
CFPropertyList (3.0.8) sha256=2c99d0d980536d3d7ab252f7bd59ac8be50fbdd1ff487c98c949bb66bb114261
|
||||
abbrev (0.1.2) sha256=ad1b4eaaaed4cb722d5684d63949e4bde1d34f2a95e20db93aecfe7cbac74242
|
||||
addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af
|
||||
artifactory (3.0.17) sha256=3023d5c964c31674090d655a516f38ca75665c15084140c08b7f2841131af263
|
||||
atomos (0.1.3) sha256=7d43b22f2454a36bace5532d30785b06de3711399cb1c6bf932573eda536789f
|
||||
aws-eventstream (1.4.0) sha256=116bf85c436200d1060811e6f5d2d40c88f65448f2125bc77ffce5121e6e183b
|
||||
aws-partitions (1.1237.0) sha256=9b82f529b69ad83a8e4c5e123038924ed5e8f59bd6064a293ef20efc63364841
|
||||
aws-sdk-core (3.244.0) sha256=3e458c078b0c5bdee95bc370c3a483374b3224cf730c1f9f0faf849a5d9a18ea
|
||||
aws-sdk-kms (1.123.0) sha256=d405f37e82f8fa32045ca8980be266c0b45b37aaf2012afe0254321a1e811f20
|
||||
aws-sdk-s3 (1.219.0) sha256=6a755d7377978525758b3c29185ca6a10128ce2b07555ca37c4549de10c2f1c7
|
||||
aws-sigv4 (1.12.1) sha256=6973ff95cb0fd0dc58ba26e90e9510a2219525d07620c8babeb70ef831826c00
|
||||
babosa (1.0.4) sha256=18dea450f595462ed7cb80595abd76b2e535db8c91b350f6c4b3d73986c5bc99
|
||||
base64 (0.2.0) sha256=0f25e9b21a02a0cc0cea8ef92b2041035d39350946e8789c562b2d1a3da01507
|
||||
benchmark (0.5.0) sha256=465df122341aedcb81a2a24b4d3bd19b6c67c1530713fd533f3ff034e419236c
|
||||
bigdecimal (4.1.1) sha256=1c09efab961da45203c8316b0cdaec0ff391dfadb952dd459584b63ebf8054ca
|
||||
claide (1.1.0) sha256=6d3c5c089dde904d96aa30e73306d0d4bd444b1accb9b3125ce14a3c0183f82e
|
||||
colored (1.2) sha256=9d82b47ac589ce7f6cab64b1f194a2009e9fd00c326a5357321f44afab2c1d2c
|
||||
colored2 (3.1.2) sha256=b13c2bd7eeae2cf7356a62501d398e72fde78780bd26aec6a979578293c28b4a
|
||||
commander (4.6.0) sha256=7d1ddc3fccae60cc906b4131b916107e2ef0108858f485fdda30610c0f2913d9
|
||||
csv (3.3.5) sha256=6e5134ac3383ef728b7f02725d9872934f523cb40b961479f69cf3afa6c8e73f
|
||||
declarative (0.0.20) sha256=8021dd6cb17ab2b61233c56903d3f5a259c5cf43c80ff332d447d395b17d9ff9
|
||||
digest-crc (0.7.0) sha256=64adc23a26a241044cbe6732477ca1b3c281d79e2240bcff275a37a5a0d78c07
|
||||
domain_name (0.6.20240107) sha256=5f693b2215708476517479bf2b3802e49068ad82167bcd2286f899536a17d933
|
||||
dotenv (2.8.1) sha256=c5944793349ae03c432e1780a2ca929d60b88c7d14d52d630db0508c3a8a17d8
|
||||
emoji_regex (3.2.3) sha256=ecd8be856b7691406c6bf3bb3a5e55d6ed683ffab98b4aa531bb90e1ddcc564b
|
||||
excon (0.112.0) sha256=daf9ac3a4c2fc9aa48383a33da77ecb44fa395111e973084d5c52f6f214ae0f0
|
||||
faraday (1.10.5) sha256=b144f1d2b045652fa820b5f532723e1643cc28b93dae911d784e5c5f88e8f6ed
|
||||
faraday-cookie_jar (0.0.8) sha256=0140605823f8cc63c7028fccee486aaed8e54835c360cffc1f7c8c07c4299dbb
|
||||
faraday-em_http (1.0.0) sha256=7a3d4c7079789121054f57e08cd4ef7e40ad1549b63101f38c7093a9d6c59689
|
||||
faraday-em_synchrony (1.0.1) sha256=bf3ce45dcf543088d319ab051f80985ea6d294930635b7a0b966563179f81750
|
||||
faraday-excon (1.1.0) sha256=b055c842376734d7f74350fe8611542ae2000c5387348d9ba9708109d6e40940
|
||||
faraday-httpclient (1.0.1) sha256=4c8ff1f0973ff835be8d043ef16aaf54f47f25b7578f6d916deee8399a04d33b
|
||||
faraday-multipart (1.2.0) sha256=7d89a949693714176f612323ca13746a2ded204031a6ba528adee788694ef757
|
||||
faraday-net_http (1.0.2) sha256=63992efea42c925a20818cf3c0830947948541fdcf345842755510d266e4c682
|
||||
faraday-net_http_persistent (1.2.0) sha256=0b0cbc8f03dab943c3e1cc58d8b7beb142d9df068b39c718cd83e39260348335
|
||||
faraday-patron (1.0.0) sha256=dc2cd7b340bb3cc8e36bcb9e6e7eff43d134b6d526d5f3429c7a7680ddd38fa7
|
||||
faraday-rack (1.0.0) sha256=ef60ec969a2bb95b8dbf24400155aee64a00fc8ba6c6a4d3968562bcc92328c0
|
||||
faraday-retry (1.0.4) sha256=dc659233777fabf96c69c2ffe56c0a5d2c102af90321a42cc6c90157bcd716aa
|
||||
faraday_middleware (1.2.1) sha256=d45b78c8ee864c4783fbc276f845243d4a7918a67301c052647bacabec0529e9
|
||||
fastimage (2.4.1) sha256=c64bebd46b6fd8943ab70c1e6e85ff728f970f2e48f92ecd249b6bc3a540ad20
|
||||
fastlane (2.232.2) sha256=978689f60f0fc3d54699de86ef12be4eda9f5b52217c1798965257c390d2b112
|
||||
fastlane-sirp (1.0.0) sha256=66478f25bcd039ec02ccf65625373fca29646fa73d655eb533c915f106c5e641
|
||||
gh_inspector (1.1.3) sha256=04cca7171b87164e053aa43147971d3b7f500fcb58177698886b48a9fc4a1939
|
||||
google-apis-androidpublisher_v3 (0.98.0) sha256=094fb952419c1131c16c4dfa66e0c96e6a2fa33adbe266f614b84b22cbc8c5cb
|
||||
google-apis-core (0.18.0) sha256=96b057816feeeab448139ed5b5c78eab7fc2a9d8958f0fbc8217dedffad054ee
|
||||
google-apis-iamcredentials_v1 (0.26.0) sha256=3ff70a10a1d6cddf2554e95b7c5df2c26afdeaeb64100048a355194da19e48a3
|
||||
google-apis-playcustomapp_v1 (0.17.0) sha256=d5bc90b705f3f862bab4998086449b0abe704ee1685a84821daa90ca7fa95a78
|
||||
google-apis-storage_v1 (0.61.0) sha256=b330e599b58e6a01533c189525398d6dbdbaf101ffb0c60145940b57e1c982e8
|
||||
google-cloud-core (1.8.0) sha256=e572edcbf189cfcab16590628a516cec3f4f63454b730e59f0b36575120281cf
|
||||
google-cloud-env (2.1.1) sha256=cf4bb8c7d517ee1ea692baedf06e0b56ce68007549d8d5a66481aa9f97f46999
|
||||
google-cloud-errors (1.6.0) sha256=1da8476dd706ad04b9d32e3c4b90d07d3463b37d6407cb56d41342ea7647d0a1
|
||||
google-cloud-storage (1.59.0) sha256=b8c9a5661d775d65ccb279bb1d6be07fd8152576eb0146c2026bd023c4b186b9
|
||||
googleauth (1.11.2) sha256=7e6bacaeed7aea3dd66dcea985266839816af6633e9f5983c3c2e0e40a44731e
|
||||
highline (2.0.3) sha256=2ddd5c127d4692721486f91737307236fe005352d12a4202e26c48614f719479
|
||||
http-cookie (1.0.8) sha256=b14fe0445cf24bf9ae098633e9b8d42e4c07c3c1f700672b09fbfe32ffd41aa6
|
||||
httpclient (2.9.0) sha256=4b645958e494b2f86c2f8a2f304c959baa273a310e77a2931ddb986d83e498c8
|
||||
jmespath (1.6.2) sha256=238d774a58723d6c090494c8879b5e9918c19485f7e840f2c1c7532cf84ebcb1
|
||||
json (2.19.3) sha256=289b0bb53052a1fa8c34ab33cc750b659ba14a5c45f3fcf4b18762dc67c78646
|
||||
jwt (2.10.2) sha256=31e1ee46f7359883d5e622446969fe9c118c3da87a0b1dca765ce269c3a0c4f4
|
||||
logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
|
||||
mini_magick (4.13.2) sha256=71d6258e0e8a3d04a9a0a09784d5d857b403a198a51dd4f882510435eb95ddd9
|
||||
mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef
|
||||
multi_json (1.19.1) sha256=7aefeff8f2c854bf739931a238e4aea64592845e0c0395c8a7d2eea7fdd631b7
|
||||
multipart-post (2.4.1) sha256=9872d03a8e552020ca096adadbf5e3cb1cd1cdd6acd3c161136b8a5737cdb4a8
|
||||
mutex_m (0.3.0) sha256=cfcb04ac16b69c4813777022fdceda24e9f798e48092a2b817eb4c0a782b0751
|
||||
nanaimo (0.4.0) sha256=faf069551bab17f15169c1f74a1c73c220657e71b6e900919897a10d991d0723
|
||||
naturally (2.3.0) sha256=459923cf76c2e6613048301742363200c3c7e4904c324097d54a67401e179e01
|
||||
nkf (0.2.0) sha256=fbc151bda025451f627fafdfcb3f4f13d0b22ae11f58c6d3a2939c76c5f5f126
|
||||
optparse (0.8.1) sha256=42bea10d53907ccff4f080a69991441d611fbf8733b60ed1ce9ee365ce03bd1a
|
||||
os (1.1.4) sha256=57816d6a334e7bd6aed048f4b0308226c5fb027433b67d90a9ab435f35108d3f
|
||||
ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912
|
||||
plist (3.7.2) sha256=d37a4527cc1116064393df4b40e1dbbc94c65fa9ca2eec52edf9a13616718a42
|
||||
public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623
|
||||
rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c
|
||||
representable (3.2.0) sha256=cc29bf7eebc31653586849371a43ffe36c60b54b0a6365b5f7d95ec34d1ebace
|
||||
retriable (3.4.1) sha256=fb3f114b7d492121c158c01f3d5152b5a615c5b70d5877d0bc08c7ec3725c3bc
|
||||
rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
|
||||
rouge (3.28.0) sha256=0d6de482c7624000d92697772ab14e48dca35629f8ddf3f4b21c99183fd70e20
|
||||
ruby2_keywords (0.0.5) sha256=ffd13740c573b7301cf7a2e61fc857b2a8e3d3aff32545d6f8300d8bae10e3ef
|
||||
rubyzip (2.4.1) sha256=8577c88edc1fde8935eb91064c5cb1aef9ad5494b940cf19c775ee833e075615
|
||||
security (0.1.5) sha256=3a977a0eca7706e804c96db0dd9619e0a94969fe3aac9680fcfc2bf9b8a833b7
|
||||
signet (0.21.0) sha256=d617e9fbf24928280d39dcfefba9a0372d1c38187ffffd0a9283957a10a8cd5b
|
||||
simctl (1.6.10) sha256=b99077f4d13ad81eace9f86bf5ba4df1b0b893a4d1b368bd3ed59b5b27f9236b
|
||||
sysrandom (1.0.5) sha256=5ac1ac3c2ec64ef76ac91018059f541b7e8f437fbda1ccddb4f2c56a9ccf1e75
|
||||
terminal-notifier (2.0.0) sha256=7a0d2b2212ab9835c07f4b2e22a94cff64149dba1eed203c04835f7991078cea
|
||||
terminal-table (3.0.2) sha256=f951b6af5f3e00203fb290a669e0a85c5dd5b051b3b023392ccfd67ba5abae91
|
||||
trailblazer-option (0.1.2) sha256=20e4f12ea4e1f718c8007e7944ca21a329eee4eed9e0fa5dde6e8ad8ac4344a3
|
||||
tty-cursor (0.7.1) sha256=79534185e6a777888d88628b14b6a1fdf5154a603f285f80b1753e1908e0bf48
|
||||
tty-screen (0.8.2) sha256=c090652115beae764336c28802d633f204fb84da93c6a968aa5d8e319e819b50
|
||||
tty-spinner (0.9.3) sha256=0e036f047b4ffb61f2aa45f5a770ec00b4d04130531558a94bfc5b192b570542
|
||||
uber (0.1.0) sha256=5beeb407ff807b5db994f82fa9ee07cfceaa561dad8af20be880bc67eba935dc
|
||||
unicode-display_width (2.6.0) sha256=12279874bba6d5e4d2728cef814b19197dbb10d7a7837a869bab65da943b7f5a
|
||||
word_wrap (1.0.0) sha256=f556d4224c812e371000f12a6ee8102e0daa724a314c3f246afaad76d82accc7
|
||||
xcodeproj (1.27.0) sha256=8cc7a73b4505c227deab044dce118ede787041c702bc47636856a2e566f854d3
|
||||
xcpretty (0.4.1) sha256=b14c50e721f6589ee3d6f5353e2c2cfcd8541fa1ea16d6c602807dd7327f3892
|
||||
xcpretty-travis-formatter (1.0.1) sha256=aacc332f17cb7b2cba222994e2adc74223db88724fe76341483ad3098e232f93
|
||||
|
||||
BUNDLED WITH
|
||||
4.0.7
|
||||
@@ -68,5 +68,10 @@
|
||||
</array>
|
||||
<key>UIStatusBarHidden</key>
|
||||
<false/>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>fetch</string>
|
||||
<string>processing</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
default_platform(:ios)
|
||||
|
||||
platform :ios do
|
||||
def api_key
|
||||
key_id = ENV.fetch("APP_STORE_API_KEY")
|
||||
app_store_connect_api_key(
|
||||
key_id: key_id,
|
||||
issuer_id: ENV.fetch("APP_STORE_ISSUER_ID"),
|
||||
key_filepath: File.join(ENV.fetch("APP_STORE_KEY_PATH"), "AuthKey_#{key_id}.p8"),
|
||||
)
|
||||
end
|
||||
|
||||
def find_ipa
|
||||
ipa_path = Dir[File.expand_path("../../build/ios/ipa/*.ipa", __dir__)].first
|
||||
UI.user_error!("No IPA found in build/ios/ipa/. Run 'make ios-build' first.") unless ipa_path
|
||||
ipa_path
|
||||
end
|
||||
|
||||
def changelog_notes
|
||||
root = File.expand_path("../..", __dir__)
|
||||
pubspec = File.read(File.join(root, "pubspec.yaml"))
|
||||
version = pubspec.match(/^version:\s*(.+)$/)[1].strip.split("+").first
|
||||
|
||||
changelog_path = File.join(root, "CHANGELOG.md")
|
||||
return "Release #{version}" unless File.exist?(changelog_path)
|
||||
|
||||
changelog = File.read(changelog_path)
|
||||
pattern = /^## \[#{Regexp.escape(version)}\].*?\n(.*?)(?=^## \[|\z)/m
|
||||
match = changelog.match(pattern)
|
||||
return "Release #{version}" unless match
|
||||
|
||||
match[1].strip
|
||||
.gsub(/\s*\(\[[\da-f]+\]\([^)]+\)\)/, "")
|
||||
.gsub(/^\*\s+\*\*[^*]+:\*\*\s*/m, "- ")
|
||||
.gsub(/^\*\s+/, "- ")
|
||||
.gsub(/^### /, "")
|
||||
.gsub(/\n{3,}/, "\n\n")
|
||||
.strip
|
||||
.then { |n| n.empty? ? "Release #{version}" : n }
|
||||
end
|
||||
|
||||
desc "Upload to TestFlight"
|
||||
lane :beta do
|
||||
upload_to_testflight(
|
||||
api_key: api_key,
|
||||
ipa: find_ipa,
|
||||
changelog: changelog_notes,
|
||||
skip_waiting_for_build_processing: true,
|
||||
)
|
||||
end
|
||||
|
||||
desc "Upload to App Store"
|
||||
lane :release do
|
||||
deliver(
|
||||
api_key: api_key,
|
||||
ipa: find_ipa,
|
||||
skip_metadata: true,
|
||||
skip_screenshots: true,
|
||||
submit_for_review: false,
|
||||
release_notes: { "en-US" => changelog_notes },
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -1,18 +1,23 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
||||
import 'i18n.dart';
|
||||
import 'services/auth_service.dart';
|
||||
import 'services/background_notification_task.dart';
|
||||
import 'services/category_service.dart';
|
||||
import 'services/checklist_service.dart';
|
||||
import 'services/house_service.dart';
|
||||
import 'services/local_notifications_service.dart';
|
||||
import 'services/note_service.dart';
|
||||
import 'services/photo_service.dart';
|
||||
import 'services/prefs_service.dart';
|
||||
import 'services/theming_service.dart';
|
||||
import 'views/home/home_view.dart';
|
||||
import 'views/login/login_view.dart';
|
||||
import 'views/notifications_intro/notifications_intro_view.dart';
|
||||
|
||||
final rootNavigatorKey = GlobalKey<NavigatorState>();
|
||||
|
||||
@@ -23,6 +28,7 @@ void main() async {
|
||||
}
|
||||
await AuthService.instance.loadCredentials();
|
||||
await PrefsService.instance.load();
|
||||
await LocalNotificationsService.instance.init();
|
||||
if (AuthService.instance.isLoggedIn) {
|
||||
await Future.wait([
|
||||
ThemingService.instance.fetchTheme(),
|
||||
@@ -32,6 +38,10 @@ void main() async {
|
||||
PhotoService.instance.cache.load(),
|
||||
NoteService.instance.cache.load(),
|
||||
]);
|
||||
// Kick off the periodic background poll if notifications are enabled.
|
||||
if (PrefsService.instance.notificationsEnabled) {
|
||||
unawaited(registerBackgroundNotificationPoll());
|
||||
}
|
||||
}
|
||||
runApp(const PantryApp());
|
||||
}
|
||||
@@ -49,11 +59,20 @@ class PantryAppState extends State<PantryApp> {
|
||||
Future<void> _onLoginSuccess() async {
|
||||
await ThemingService.instance.fetchTheme();
|
||||
_isLoggedIn = true;
|
||||
rootNavigatorKey.currentState?.pushReplacementNamed('/home');
|
||||
final nextRoute = PrefsService.instance.notificationsIntroSeen
|
||||
? '/home'
|
||||
: '/notifications-intro';
|
||||
rootNavigatorKey.currentState?.pushReplacementNamed(nextRoute);
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
void _onIntroDone() {
|
||||
rootNavigatorKey.currentState?.pushReplacementNamed('/home');
|
||||
}
|
||||
|
||||
Future<void> _onLogout() async {
|
||||
await cancelBackgroundNotificationPoll();
|
||||
await LocalNotificationsService.instance.cancelAll();
|
||||
await AuthService.instance.logout();
|
||||
ThemingService.instance.clear();
|
||||
await Future.wait([
|
||||
@@ -106,7 +125,9 @@ class PantryAppState extends State<PantryApp> {
|
||||
onGenerateInitialRoutes: (initialRoute) => [
|
||||
MaterialPageRoute(
|
||||
builder: (_) => _isLoggedIn
|
||||
? HomeView(onLogout: _onLogout)
|
||||
? (PrefsService.instance.notificationsIntroSeen
|
||||
? HomeView(onLogout: _onLogout)
|
||||
: NotificationsIntroView(onDone: _onIntroDone))
|
||||
: LoginView(onLoginSuccess: _onLoginSuccess),
|
||||
),
|
||||
],
|
||||
@@ -116,6 +137,10 @@ class PantryAppState extends State<PantryApp> {
|
||||
return MaterialPageRoute(
|
||||
builder: (_) => HomeView(onLogout: _onLogout),
|
||||
);
|
||||
case '/notifications-intro':
|
||||
return MaterialPageRoute(
|
||||
builder: (_) => NotificationsIntroView(onDone: _onIntroDone),
|
||||
);
|
||||
case '/login':
|
||||
default:
|
||||
return MaterialPageRoute(
|
||||
|
||||
@@ -66,6 +66,10 @@ class Messages {
|
||||
LoginMessages get login => LoginMessages(this);
|
||||
HomeMessages get home => HomeMessages(this);
|
||||
NavMessages get nav => NavMessages(this);
|
||||
NotificationsIntroMessages get notificationsIntro =>
|
||||
NotificationsIntroMessages(this);
|
||||
SettingsMessages get settings => SettingsMessages(this);
|
||||
NotificationsMessages get notifications => NotificationsMessages(this);
|
||||
CategoriesMessages get categories => CategoriesMessages(this);
|
||||
ChecklistsMessages get checklists => ChecklistsMessages(this);
|
||||
NotesWallMessages get notesWall => NotesWallMessages(this);
|
||||
@@ -245,6 +249,170 @@ class NavMessages {
|
||||
String get notesWall => """Notes Wall""";
|
||||
}
|
||||
|
||||
class NotificationsIntroMessages {
|
||||
final Messages _parent;
|
||||
const NotificationsIntroMessages(this._parent);
|
||||
|
||||
/// ```dart
|
||||
/// "Stay in the loop"
|
||||
/// ```
|
||||
String get title => """Stay in the loop""";
|
||||
|
||||
/// ```dart
|
||||
/// "Pantry can notify you when household members add items to checklists, upload photos, or leave notes. Notifications are fetched from your own Nextcloud server — nothing goes through Google or third parties."
|
||||
/// ```
|
||||
String get body =>
|
||||
"""Pantry can notify you when household members add items to checklists, upload photos, or leave notes. Notifications are fetched from your own Nextcloud server — nothing goes through Google or third parties.""";
|
||||
|
||||
/// ```dart
|
||||
/// "Household activity alerts"
|
||||
/// ```
|
||||
String get bullet1 => """Household activity alerts""";
|
||||
|
||||
/// ```dart
|
||||
/// "Fetched directly from your server"
|
||||
/// ```
|
||||
String get bullet2 => """Fetched directly from your server""";
|
||||
|
||||
/// ```dart
|
||||
/// "Works even when the app is closed"
|
||||
/// ```
|
||||
String get bullet3 => """Works even when the app is closed""";
|
||||
|
||||
/// ```dart
|
||||
/// "Enable notifications"
|
||||
/// ```
|
||||
String get enableButton => """Enable notifications""";
|
||||
|
||||
/// ```dart
|
||||
/// "Not now"
|
||||
/// ```
|
||||
String get skipButton => """Not now""";
|
||||
|
||||
/// ```dart
|
||||
/// "Permission denied"
|
||||
/// ```
|
||||
String get permissionDeniedTitle => """Permission denied""";
|
||||
|
||||
/// ```dart
|
||||
/// "You can enable notifications later in App Settings. If your device blocks them, you'll need to allow them in system settings first."
|
||||
/// ```
|
||||
String get permissionDeniedBody =>
|
||||
"""You can enable notifications later in App Settings. If your device blocks them, you'll need to allow them in system settings first.""";
|
||||
|
||||
/// ```dart
|
||||
/// "OK"
|
||||
/// ```
|
||||
String get ok => """OK""";
|
||||
}
|
||||
|
||||
class SettingsMessages {
|
||||
final Messages _parent;
|
||||
const SettingsMessages(this._parent);
|
||||
|
||||
/// ```dart
|
||||
/// "App Settings"
|
||||
/// ```
|
||||
String get title => """App Settings""";
|
||||
|
||||
/// ```dart
|
||||
/// "Notifications"
|
||||
/// ```
|
||||
String get notificationsSection => """Notifications""";
|
||||
|
||||
/// ```dart
|
||||
/// "Enable notifications"
|
||||
/// ```
|
||||
String get enableNotifications => """Enable notifications""";
|
||||
|
||||
/// ```dart
|
||||
/// "Show alerts when household members add or update content."
|
||||
/// ```
|
||||
String get enableNotificationsBody =>
|
||||
"""Show alerts when household members add or update content.""";
|
||||
|
||||
/// ```dart
|
||||
/// "Check for new activity"
|
||||
/// ```
|
||||
String get pollInterval => """Check for new activity""";
|
||||
|
||||
/// ```dart
|
||||
/// "Every 15 minutes"
|
||||
/// ```
|
||||
String get pollInterval15m => """Every 15 minutes""";
|
||||
|
||||
/// ```dart
|
||||
/// "Every 30 minutes"
|
||||
/// ```
|
||||
String get pollInterval30m => """Every 30 minutes""";
|
||||
|
||||
/// ```dart
|
||||
/// "Every hour"
|
||||
/// ```
|
||||
String get pollInterval1h => """Every hour""";
|
||||
|
||||
/// ```dart
|
||||
/// "Every 2 hours"
|
||||
/// ```
|
||||
String get pollInterval2h => """Every 2 hours""";
|
||||
|
||||
/// ```dart
|
||||
/// "Every 6 hours"
|
||||
/// ```
|
||||
String get pollInterval6h => """Every 6 hours""";
|
||||
|
||||
/// ```dart
|
||||
/// "Notification permission was denied. Enable it in system settings."
|
||||
/// ```
|
||||
String get permissionDenied =>
|
||||
"""Notification permission was denied. Enable it in system settings.""";
|
||||
}
|
||||
|
||||
class NotificationsMessages {
|
||||
final Messages _parent;
|
||||
const NotificationsMessages(this._parent);
|
||||
|
||||
/// ```dart
|
||||
/// "Notifications"
|
||||
/// ```
|
||||
String get title => """Notifications""";
|
||||
|
||||
/// ```dart
|
||||
/// "No new notifications."
|
||||
/// ```
|
||||
String get empty => """No new notifications.""";
|
||||
|
||||
/// ```dart
|
||||
/// "Failed to load notifications."
|
||||
/// ```
|
||||
String get failedToLoad => """Failed to load notifications.""";
|
||||
|
||||
/// ```dart
|
||||
/// "Dismiss all"
|
||||
/// ```
|
||||
String get dismissAll => """Dismiss all""";
|
||||
|
||||
/// ```dart
|
||||
/// "just now"
|
||||
/// ```
|
||||
String get justNow => """just now""";
|
||||
|
||||
/// ```dart
|
||||
/// "${count}m ago"
|
||||
/// ```
|
||||
String minutesAgo(int count) => """${count}m ago""";
|
||||
|
||||
/// ```dart
|
||||
/// "${count}h ago"
|
||||
/// ```
|
||||
String hoursAgo(int count) => """${count}h ago""";
|
||||
|
||||
/// ```dart
|
||||
/// "${count}d ago"
|
||||
/// ```
|
||||
String daysAgo(int count) => """${count}d ago""";
|
||||
}
|
||||
|
||||
class CategoriesMessages {
|
||||
final Messages _parent;
|
||||
const CategoriesMessages(this._parent);
|
||||
@@ -349,6 +517,41 @@ class ChecklistsMessages {
|
||||
/// "Remove item"
|
||||
/// ```
|
||||
String get removeItem => """Remove item""";
|
||||
|
||||
/// ```dart
|
||||
/// "Move to list"
|
||||
/// ```
|
||||
String get moveItem => """Move to list""";
|
||||
|
||||
/// ```dart
|
||||
/// "Failed to move item."
|
||||
/// ```
|
||||
String get moveFailed => """Failed to move item.""";
|
||||
|
||||
/// ```dart
|
||||
/// "New list"
|
||||
/// ```
|
||||
String get createList => """New list""";
|
||||
|
||||
/// ```dart
|
||||
/// "List name"
|
||||
/// ```
|
||||
String get listName => """List name""";
|
||||
|
||||
/// ```dart
|
||||
/// "Description (optional)"
|
||||
/// ```
|
||||
String get listDescription => """Description (optional)""";
|
||||
|
||||
/// ```dart
|
||||
/// "Icon"
|
||||
/// ```
|
||||
String get listIcon => """Icon""";
|
||||
|
||||
/// ```dart
|
||||
/// "Failed to create list."
|
||||
/// ```
|
||||
String get createListFailed => """Failed to create list.""";
|
||||
ViewItemChecklistsMessages get viewItem => ViewItemChecklistsMessages(this);
|
||||
ItemFormChecklistsMessages get itemForm => ItemFormChecklistsMessages(this);
|
||||
SortChecklistsMessages get sort => SortChecklistsMessages(this);
|
||||
@@ -493,6 +696,11 @@ class SortChecklistsMessages {
|
||||
/// ```
|
||||
String get nameZA => """Name Z–A""";
|
||||
|
||||
/// ```dart
|
||||
/// "By category"
|
||||
/// ```
|
||||
String get category => """By category""";
|
||||
|
||||
/// ```dart
|
||||
/// "Custom"
|
||||
/// ```
|
||||
@@ -988,6 +1196,36 @@ Please complete login in your browser.""",
|
||||
"""nav.checklists""": """Checklists""",
|
||||
"""nav.photoBoard""": """Photo Board""",
|
||||
"""nav.notesWall""": """Notes Wall""",
|
||||
"""notificationsIntro.title""": """Stay in the loop""",
|
||||
"""notificationsIntro.body""":
|
||||
"""Pantry can notify you when household members add items to checklists, upload photos, or leave notes. Notifications are fetched from your own Nextcloud server — nothing goes through Google or third parties.""",
|
||||
"""notificationsIntro.bullet1""": """Household activity alerts""",
|
||||
"""notificationsIntro.bullet2""": """Fetched directly from your server""",
|
||||
"""notificationsIntro.bullet3""": """Works even when the app is closed""",
|
||||
"""notificationsIntro.enableButton""": """Enable notifications""",
|
||||
"""notificationsIntro.skipButton""": """Not now""",
|
||||
"""notificationsIntro.permissionDeniedTitle""": """Permission denied""",
|
||||
"""notificationsIntro.permissionDeniedBody""":
|
||||
"""You can enable notifications later in App Settings. If your device blocks them, you'll need to allow them in system settings first.""",
|
||||
"""notificationsIntro.ok""": """OK""",
|
||||
"""settings.title""": """App Settings""",
|
||||
"""settings.notificationsSection""": """Notifications""",
|
||||
"""settings.enableNotifications""": """Enable notifications""",
|
||||
"""settings.enableNotificationsBody""":
|
||||
"""Show alerts when household members add or update content.""",
|
||||
"""settings.pollInterval""": """Check for new activity""",
|
||||
"""settings.pollInterval15m""": """Every 15 minutes""",
|
||||
"""settings.pollInterval30m""": """Every 30 minutes""",
|
||||
"""settings.pollInterval1h""": """Every hour""",
|
||||
"""settings.pollInterval2h""": """Every 2 hours""",
|
||||
"""settings.pollInterval6h""": """Every 6 hours""",
|
||||
"""settings.permissionDenied""":
|
||||
"""Notification permission was denied. Enable it in system settings.""",
|
||||
"""notifications.title""": """Notifications""",
|
||||
"""notifications.empty""": """No new notifications.""",
|
||||
"""notifications.failedToLoad""": """Failed to load notifications.""",
|
||||
"""notifications.dismissAll""": """Dismiss all""",
|
||||
"""notifications.justNow""": """just now""",
|
||||
"""categories.manageTitle""": """Manage categories""",
|
||||
"""categories.noCategories""": """No categories yet.""",
|
||||
"""categories.editTitle""": """Edit category""",
|
||||
@@ -1007,6 +1245,13 @@ Please complete login in your browser.""",
|
||||
"""checklists.failedToLoadItems""": """Failed to load items.""",
|
||||
"""checklists.editItem""": """Edit item""",
|
||||
"""checklists.removeItem""": """Remove item""",
|
||||
"""checklists.moveItem""": """Move to list""",
|
||||
"""checklists.moveFailed""": """Failed to move item.""",
|
||||
"""checklists.createList""": """New list""",
|
||||
"""checklists.listName""": """List name""",
|
||||
"""checklists.listDescription""": """Description (optional)""",
|
||||
"""checklists.listIcon""": """Icon""",
|
||||
"""checklists.createListFailed""": """Failed to create list.""",
|
||||
"""checklists.viewItem.quantity""": """Quantity:""",
|
||||
"""checklists.viewItem.category""": """Category:""",
|
||||
"""checklists.viewItem.recurrence""": """Recurrence:""",
|
||||
@@ -1033,6 +1278,7 @@ Please complete login in your browser.""",
|
||||
"""checklists.sort.oldestFirst""": """Oldest first""",
|
||||
"""checklists.sort.nameAZ""": """Name A–Z""",
|
||||
"""checklists.sort.nameZA""": """Name Z–A""",
|
||||
"""checklists.sort.category""": """By category""",
|
||||
"""checklists.sort.custom""": """Custom""",
|
||||
"""notesWall.noNotes""": """No notes yet.""",
|
||||
"""notesWall.failedToLoad""": """Failed to load notes.""",
|
||||
|
||||
@@ -35,6 +35,41 @@ nav:
|
||||
photoBoard: Photo Board
|
||||
notesWall: Notes Wall
|
||||
|
||||
notificationsIntro:
|
||||
title: Stay in the loop
|
||||
body: "Pantry can notify you when household members add items to checklists, upload photos, or leave notes. Notifications are fetched from your own Nextcloud server — nothing goes through Google or third parties."
|
||||
bullet1: Household activity alerts
|
||||
bullet2: Fetched directly from your server
|
||||
bullet3: Works even when the app is closed
|
||||
enableButton: Enable notifications
|
||||
skipButton: Not now
|
||||
permissionDeniedTitle: Permission denied
|
||||
permissionDeniedBody: "You can enable notifications later in App Settings. If your device blocks them, you'll need to allow them in system settings first."
|
||||
ok: OK
|
||||
|
||||
settings:
|
||||
title: App Settings
|
||||
notificationsSection: Notifications
|
||||
enableNotifications: Enable notifications
|
||||
enableNotificationsBody: Show alerts when household members add or update content.
|
||||
pollInterval: Check for new activity
|
||||
pollInterval15m: Every 15 minutes
|
||||
pollInterval30m: Every 30 minutes
|
||||
pollInterval1h: Every hour
|
||||
pollInterval2h: Every 2 hours
|
||||
pollInterval6h: Every 6 hours
|
||||
permissionDenied: Notification permission was denied. Enable it in system settings.
|
||||
|
||||
notifications:
|
||||
title: Notifications
|
||||
empty: No new notifications.
|
||||
failedToLoad: Failed to load notifications.
|
||||
dismissAll: Dismiss all
|
||||
justNow: just now
|
||||
minutesAgo(int count): "${count}m ago"
|
||||
hoursAgo(int count): "${count}h ago"
|
||||
daysAgo(int count): "${count}d ago"
|
||||
|
||||
categories:
|
||||
manageTitle: Manage categories
|
||||
noCategories: No categories yet.
|
||||
@@ -57,6 +92,13 @@ checklists:
|
||||
completedCount(int count): "Completed ($count)"
|
||||
editItem: Edit item
|
||||
removeItem: Remove item
|
||||
moveItem: Move to list
|
||||
moveFailed: Failed to move item.
|
||||
createList: New list
|
||||
listName: List name
|
||||
listDescription: Description (optional)
|
||||
listIcon: Icon
|
||||
createListFailed: Failed to create list.
|
||||
viewItem:
|
||||
quantity: "Quantity:"
|
||||
category: "Category:"
|
||||
@@ -85,6 +127,7 @@ checklists:
|
||||
oldestFirst: Oldest first
|
||||
nameAZ: "Name A\u2013Z"
|
||||
nameZA: "Name Z\u2013A"
|
||||
category: By category
|
||||
custom: Custom
|
||||
|
||||
notesWall:
|
||||
|
||||
77
lib/models/notification.dart
Normal file
@@ -0,0 +1,77 @@
|
||||
/// Which tab a notification maps to on the home screen.
|
||||
enum NotificationTarget { checklists, photos, notes }
|
||||
|
||||
/// A Nextcloud notification as returned by the Notifications OCS API.
|
||||
class NcNotification {
|
||||
final int notificationId;
|
||||
final String app;
|
||||
final String user;
|
||||
final String subject;
|
||||
final String message;
|
||||
final String datetime;
|
||||
final String objectType;
|
||||
final String objectId;
|
||||
final String? icon;
|
||||
final String? link;
|
||||
final Map<String, dynamic> subjectRichParameters;
|
||||
|
||||
const NcNotification({
|
||||
required this.notificationId,
|
||||
required this.app,
|
||||
required this.user,
|
||||
required this.subject,
|
||||
required this.message,
|
||||
required this.datetime,
|
||||
required this.objectType,
|
||||
required this.objectId,
|
||||
this.icon,
|
||||
this.link,
|
||||
this.subjectRichParameters = const {},
|
||||
});
|
||||
|
||||
factory NcNotification.fromJson(Map<String, dynamic> json) => NcNotification(
|
||||
notificationId: json['notification_id'] as int,
|
||||
app: json['app'] as String? ?? '',
|
||||
user: json['user'] as String? ?? '',
|
||||
// Prefer `subject` (already-parsed plain text) over `subjectRich`
|
||||
// (template with `{placeholder}` tokens). Nextcloud populates both.
|
||||
subject:
|
||||
(json['subject'] as String?) ?? (json['subjectRich'] as String?) ?? '',
|
||||
message:
|
||||
(json['message'] as String?) ?? (json['messageRich'] as String?) ?? '',
|
||||
datetime: json['datetime'] as String? ?? '',
|
||||
objectType: json['object_type'] as String? ?? '',
|
||||
objectId: json['object_id'] as String? ?? '',
|
||||
icon: json['icon'] as String?,
|
||||
link: json['link'] as String?,
|
||||
subjectRichParameters:
|
||||
(json['subjectRichParameters'] as Map<String, dynamic>?) ?? const {},
|
||||
);
|
||||
|
||||
/// Parsed timestamp or null if unparseable.
|
||||
DateTime? get parsedDatetime => DateTime.tryParse(datetime);
|
||||
|
||||
/// House id extracted from the rich parameters, if present.
|
||||
int? get houseId {
|
||||
final house = subjectRichParameters['house'];
|
||||
if (house is! Map) return null;
|
||||
final id = house['id'];
|
||||
if (id is int) return id;
|
||||
if (id is String) return int.tryParse(id);
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Which tab this notification should open on tap.
|
||||
NotificationTarget? get target {
|
||||
switch (objectType) {
|
||||
case 'photo':
|
||||
return NotificationTarget.photos;
|
||||
case 'note':
|
||||
return NotificationTarget.notes;
|
||||
case 'item':
|
||||
return NotificationTarget.checklists;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,10 +14,17 @@ class ApiException implements Exception {
|
||||
}
|
||||
|
||||
class ApiClient {
|
||||
ApiClient._();
|
||||
static final ApiClient instance = ApiClient._();
|
||||
final String basePath;
|
||||
|
||||
static const _basePath = '/ocs/v2.php/apps/pantry/api';
|
||||
/// Creates a client for the given base path (appended to the server URL
|
||||
/// from [AuthService]). Use [ApiClient.instance] for the default Pantry
|
||||
/// endpoint.
|
||||
const ApiClient({required this.basePath});
|
||||
|
||||
/// Default Pantry app API client.
|
||||
static const ApiClient instance = ApiClient(
|
||||
basePath: '/ocs/v2.php/apps/pantry/api',
|
||||
);
|
||||
|
||||
NextcloudCredentials get _credentials {
|
||||
final creds = AuthService.instance.credentials;
|
||||
@@ -28,7 +35,7 @@ class ApiClient {
|
||||
Uri _uri(String path, [Map<String, String>? queryParameters]) {
|
||||
final base = Uri.parse(_credentials.serverUrl);
|
||||
return base.replace(
|
||||
path: '$_basePath$path',
|
||||
path: '$basePath$path',
|
||||
queryParameters: queryParameters,
|
||||
);
|
||||
}
|
||||
|
||||
119
lib/services/background_notification_task.dart
Normal file
@@ -0,0 +1,119 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:pantry/models/notification.dart';
|
||||
import 'package:pantry/services/auth_service.dart';
|
||||
import 'package:pantry/services/deep_link_service.dart';
|
||||
import 'package:pantry/services/local_notifications_service.dart';
|
||||
import 'package:pantry/services/notification_service.dart';
|
||||
import 'package:pantry/services/prefs_service.dart';
|
||||
import 'package:workmanager/workmanager.dart';
|
||||
|
||||
/// Unique name for the periodic notification poll task.
|
||||
const notificationPollTaskName = 'pantry-notification-poll';
|
||||
|
||||
/// Secure storage key used to persist which notification IDs we've
|
||||
/// already shown as local notifications (to avoid re-notifying).
|
||||
const _seenIdsKey = 'seen_notification_ids';
|
||||
|
||||
/// Top-level function required by workmanager. Must be annotated
|
||||
/// `@pragma('vm:entry-point')` so tree-shaking doesn't strip it.
|
||||
@pragma('vm:entry-point')
|
||||
void backgroundCallbackDispatcher() {
|
||||
Workmanager().executeTask((task, inputData) async {
|
||||
if (task != notificationPollTaskName) return true;
|
||||
try {
|
||||
await _pollAndNotify();
|
||||
} catch (e) {
|
||||
debugPrint('[bg-notify] task failed: $e');
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _pollAndNotify() async {
|
||||
// In a background isolate, AuthService and PrefsService are fresh
|
||||
// instances — load credentials + user prefs.
|
||||
await AuthService.instance.loadCredentials();
|
||||
if (!AuthService.instance.isLoggedIn) return;
|
||||
|
||||
await PrefsService.instance.load();
|
||||
if (!PrefsService.instance.notificationsEnabled) return;
|
||||
|
||||
final notifications = await NotificationService.instance.getNotifications();
|
||||
if (notifications.isEmpty) return;
|
||||
|
||||
// Compare against stored seen IDs.
|
||||
const storage = FlutterSecureStorage();
|
||||
final seenRaw = await storage.read(key: _seenIdsKey);
|
||||
final seen = seenRaw == null || seenRaw.isEmpty
|
||||
? <int>{}
|
||||
: seenRaw.split(',').map(int.parse).toSet();
|
||||
|
||||
final newOnes = notifications
|
||||
.where((n) => !seen.contains(n.notificationId))
|
||||
.toList();
|
||||
|
||||
if (newOnes.isEmpty) return;
|
||||
|
||||
await LocalNotificationsService.instance.init();
|
||||
|
||||
for (final n in newOnes) {
|
||||
await LocalNotificationsService.instance.show(
|
||||
id: n.notificationId,
|
||||
title: n.subject,
|
||||
body: n.message.isNotEmpty ? n.message : null,
|
||||
payload: _payloadFor(n),
|
||||
);
|
||||
}
|
||||
|
||||
// Persist the current set of IDs (drop stale entries — keep only what the
|
||||
// server still returns, to avoid the list growing unbounded).
|
||||
final currentIds = notifications.map((n) => n.notificationId).toSet();
|
||||
await storage.write(key: _seenIdsKey, value: currentIds.join(','));
|
||||
}
|
||||
|
||||
/// Marks the currently visible notifications as "seen" without showing
|
||||
/// a local notification. Called from the foreground after the user
|
||||
/// opens the app so we don't re-alert them.
|
||||
Future<void> markCurrentNotificationsAsSeen(List<int> ids) async {
|
||||
const storage = FlutterSecureStorage();
|
||||
await storage.write(key: _seenIdsKey, value: ids.join(','));
|
||||
}
|
||||
|
||||
/// Schedule the periodic background poll using the user's configured
|
||||
/// interval from [PrefsService] (minimum 15 minutes on Android).
|
||||
Future<void> registerBackgroundNotificationPoll() async {
|
||||
await Workmanager().initialize(backgroundCallbackDispatcher);
|
||||
final minutes = PrefsService.instance.pollIntervalMinutes;
|
||||
// Android enforces a 15-minute minimum for periodic tasks.
|
||||
final clamped = minutes < 15 ? 15 : minutes;
|
||||
await Workmanager().registerPeriodicTask(
|
||||
notificationPollTaskName,
|
||||
notificationPollTaskName,
|
||||
frequency: Duration(minutes: clamped),
|
||||
constraints: Constraints(networkType: NetworkType.connected),
|
||||
existingWorkPolicy: ExistingPeriodicWorkPolicy.replace,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> cancelBackgroundNotificationPoll() async {
|
||||
await Workmanager().cancelByUniqueName(notificationPollTaskName);
|
||||
}
|
||||
|
||||
/// Cancel then re-register with the latest interval. Call this after
|
||||
/// the user changes poll frequency in settings.
|
||||
Future<void> rescheduleBackgroundNotificationPoll() async {
|
||||
await cancelBackgroundNotificationPoll();
|
||||
await registerBackgroundNotificationPoll();
|
||||
}
|
||||
|
||||
String? _payloadFor(NcNotification n) {
|
||||
final target = n.target;
|
||||
if (target == null) return null;
|
||||
final tab = switch (target) {
|
||||
NotificationTarget.checklists => 0,
|
||||
NotificationTarget.photos => 1,
|
||||
NotificationTarget.notes => 2,
|
||||
};
|
||||
return DeepLink(tabIndex: tab, houseId: n.houseId).encode();
|
||||
}
|
||||
@@ -69,15 +69,15 @@ class ChecklistService {
|
||||
|
||||
Future<String> getItemSortPref(int houseId) async {
|
||||
return ApiClient.instance.get<Map<String, dynamic>, String>(
|
||||
'/houses/$houseId/prefs/checklist-item-sort',
|
||||
fromJson: (data) => data['sort'] as String? ?? 'custom',
|
||||
'/houses/$houseId/prefs',
|
||||
fromJson: (data) => data['checklistItemSort'] as String? ?? 'custom',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setItemSortPref(int houseId, String sort) async {
|
||||
await ApiClient.instance.put<Map<String, dynamic>, void>(
|
||||
'/houses/$houseId/prefs/checklist-item-sort',
|
||||
body: {'sort': sort},
|
||||
'/houses/$houseId/prefs',
|
||||
body: {'checklistItemSort': sort},
|
||||
fromJson: (_) {},
|
||||
);
|
||||
}
|
||||
@@ -95,6 +95,37 @@ class ChecklistService {
|
||||
});
|
||||
}
|
||||
|
||||
Future<ChecklistList> createList(
|
||||
int houseId, {
|
||||
required String name,
|
||||
String? description,
|
||||
String? icon,
|
||||
}) async {
|
||||
return ApiClient.instance.post<Map<String, dynamic>, ChecklistList>(
|
||||
'/houses/$houseId/lists',
|
||||
body: {
|
||||
'name': name,
|
||||
if (description != null && description.isNotEmpty)
|
||||
'description': description,
|
||||
if (icon != null && icon.isNotEmpty) 'icon': icon,
|
||||
},
|
||||
fromJson: (data) => ChecklistList.fromJson(data),
|
||||
);
|
||||
}
|
||||
|
||||
Future<ListItem> moveItem(
|
||||
int houseId,
|
||||
int listId,
|
||||
int itemId, {
|
||||
required int targetListId,
|
||||
}) async {
|
||||
return ApiClient.instance.patch<Map<String, dynamic>, ListItem>(
|
||||
'/houses/$houseId/lists/$listId/items/$itemId',
|
||||
body: {'targetListId': targetListId},
|
||||
fromJson: (data) => ListItem.fromJson(data),
|
||||
);
|
||||
}
|
||||
|
||||
Future<ListItem> createItem(
|
||||
int houseId,
|
||||
int listId, {
|
||||
|
||||
48
lib/services/deep_link_service.dart
Normal file
@@ -0,0 +1,48 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// A deferred navigation intent produced by a notification tap. The home
|
||||
/// view consumes these and switches to the correct tab + house.
|
||||
class DeepLink {
|
||||
/// 0 = checklists, 1 = photos, 2 = notes
|
||||
final int tabIndex;
|
||||
final int? houseId;
|
||||
|
||||
const DeepLink({required this.tabIndex, this.houseId});
|
||||
|
||||
/// Serialize to a compact string for notification payloads.
|
||||
String encode() => '$tabIndex:${houseId ?? ''}';
|
||||
|
||||
/// Parse a payload string. Returns null if invalid.
|
||||
static DeepLink? decode(String? payload) {
|
||||
if (payload == null || payload.isEmpty) return null;
|
||||
final parts = payload.split(':');
|
||||
if (parts.isEmpty) return null;
|
||||
final tab = int.tryParse(parts[0]);
|
||||
if (tab == null || tab < 0 || tab > 2) return null;
|
||||
final houseId = parts.length > 1 && parts[1].isNotEmpty
|
||||
? int.tryParse(parts[1])
|
||||
: null;
|
||||
return DeepLink(tabIndex: tab, houseId: houseId);
|
||||
}
|
||||
}
|
||||
|
||||
/// Singleton holding the most recent pending deep link. The home view
|
||||
/// observes [pending] via [ValueListenable] and consumes it on navigation.
|
||||
class DeepLinkService {
|
||||
DeepLinkService._();
|
||||
static final DeepLinkService instance = DeepLinkService._();
|
||||
|
||||
final ValueNotifier<DeepLink?> pending = ValueNotifier(null);
|
||||
|
||||
/// Schedule a deep link. Called from notification tap handlers.
|
||||
void push(DeepLink link) {
|
||||
pending.value = link;
|
||||
}
|
||||
|
||||
/// Consume (and clear) the current pending link. Returns null if none.
|
||||
DeepLink? consume() {
|
||||
final link = pending.value;
|
||||
pending.value = null;
|
||||
return link;
|
||||
}
|
||||
}
|
||||
119
lib/services/local_notifications_service.dart
Normal file
@@ -0,0 +1,119 @@
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:pantry/services/deep_link_service.dart';
|
||||
|
||||
class LocalNotificationsService {
|
||||
LocalNotificationsService._();
|
||||
static final LocalNotificationsService instance =
|
||||
LocalNotificationsService._();
|
||||
|
||||
final _plugin = FlutterLocalNotificationsPlugin();
|
||||
bool _initialized = false;
|
||||
|
||||
static const _channelId = 'pantry_notifications';
|
||||
static const _channelName = 'Pantry notifications';
|
||||
static const _channelDescription = 'Household activity from your pantry';
|
||||
|
||||
Future<void> init() async {
|
||||
if (_initialized) return;
|
||||
|
||||
const androidSettings = AndroidInitializationSettings(
|
||||
'@mipmap/ic_launcher',
|
||||
);
|
||||
const iosSettings = DarwinInitializationSettings(
|
||||
requestAlertPermission: false,
|
||||
requestBadgePermission: false,
|
||||
requestSoundPermission: false,
|
||||
);
|
||||
const settings = InitializationSettings(
|
||||
android: androidSettings,
|
||||
iOS: iosSettings,
|
||||
);
|
||||
|
||||
await _plugin.initialize(
|
||||
settings,
|
||||
onDidReceiveNotificationResponse: _onTap,
|
||||
);
|
||||
|
||||
// If the app was launched by tapping a notification (cold start),
|
||||
// capture that payload so the home view can consume it after startup.
|
||||
final launchDetails = await _plugin.getNotificationAppLaunchDetails();
|
||||
if (launchDetails?.didNotificationLaunchApp ?? false) {
|
||||
final payload = launchDetails?.notificationResponse?.payload;
|
||||
final link = DeepLink.decode(payload);
|
||||
if (link != null) DeepLinkService.instance.push(link);
|
||||
}
|
||||
|
||||
// Create the Android channel (no-op on iOS).
|
||||
final androidPlugin = _plugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>();
|
||||
await androidPlugin?.createNotificationChannel(
|
||||
const AndroidNotificationChannel(
|
||||
_channelId,
|
||||
_channelName,
|
||||
description: _channelDescription,
|
||||
importance: Importance.defaultImportance,
|
||||
),
|
||||
);
|
||||
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
/// Request runtime notification permission (Android 13+ and iOS).
|
||||
Future<bool> requestPermission() async {
|
||||
await init();
|
||||
|
||||
final android = _plugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>();
|
||||
final androidGranted =
|
||||
await android?.requestNotificationsPermission() ?? true;
|
||||
|
||||
final ios = _plugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
IOSFlutterLocalNotificationsPlugin
|
||||
>();
|
||||
final iosGranted =
|
||||
await ios?.requestPermissions(alert: true, badge: true, sound: true) ??
|
||||
true;
|
||||
|
||||
return androidGranted && iosGranted;
|
||||
}
|
||||
|
||||
Future<void> show({
|
||||
required int id,
|
||||
required String title,
|
||||
String? body,
|
||||
String? payload,
|
||||
}) async {
|
||||
await init();
|
||||
await _plugin.show(
|
||||
id,
|
||||
title,
|
||||
body,
|
||||
const NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
_channelId,
|
||||
_channelName,
|
||||
channelDescription: _channelDescription,
|
||||
importance: Importance.defaultImportance,
|
||||
priority: Priority.defaultPriority,
|
||||
),
|
||||
iOS: DarwinNotificationDetails(),
|
||||
),
|
||||
payload: payload,
|
||||
);
|
||||
}
|
||||
|
||||
static void _onTap(NotificationResponse response) {
|
||||
final link = DeepLink.decode(response.payload);
|
||||
if (link != null) DeepLinkService.instance.push(link);
|
||||
}
|
||||
|
||||
Future<void> cancelAll() async {
|
||||
await init();
|
||||
await _plugin.cancelAll();
|
||||
}
|
||||
}
|
||||
50
lib/services/notification_service.dart
Normal file
@@ -0,0 +1,50 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:pantry/models/notification.dart';
|
||||
import 'package:pantry/services/api_client.dart';
|
||||
|
||||
class NotificationService {
|
||||
NotificationService._();
|
||||
static final NotificationService instance = NotificationService._();
|
||||
|
||||
static const _client = ApiClient(
|
||||
basePath: '/ocs/v2.php/apps/notifications/api/v2',
|
||||
);
|
||||
|
||||
/// Fetch all notifications, filtered to this app only.
|
||||
Future<List<NcNotification>> getNotifications() async {
|
||||
try {
|
||||
return await _client.get<List, List<NcNotification>>(
|
||||
'/notifications',
|
||||
fromJson: (data) => data
|
||||
.map((e) => NcNotification.fromJson(e as Map<String, dynamic>))
|
||||
.where((n) => n.app == 'pantry')
|
||||
.toList(),
|
||||
);
|
||||
} on ApiException catch (e) {
|
||||
// Notifications app not installed / disabled
|
||||
if (e.statusCode == 404) return [];
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete (mark as read) a single notification.
|
||||
Future<void> dismiss(int notificationId) async {
|
||||
try {
|
||||
await _client.delete('/notifications/$notificationId');
|
||||
} on ApiException catch (e) {
|
||||
if (e.statusCode == 404) return; // already gone
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete all given notifications.
|
||||
Future<void> dismissAll(List<int> ids) async {
|
||||
for (final id in ids) {
|
||||
try {
|
||||
await dismiss(id);
|
||||
} catch (e) {
|
||||
debugPrint('[NotificationService] Failed to dismiss $id: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,16 +5,38 @@ class PrefsService {
|
||||
static final PrefsService instance = PrefsService._();
|
||||
|
||||
static const _lastHouseKey = 'last_house_id';
|
||||
static const _notificationsEnabledKey = 'notifications_enabled';
|
||||
static const _pollIntervalMinutesKey = 'poll_interval_minutes';
|
||||
static const _notificationsIntroSeenKey = 'notifications_intro_seen';
|
||||
final _storage = const FlutterSecureStorage();
|
||||
|
||||
int? _lastHouseId;
|
||||
int? get lastHouseId => _lastHouseId;
|
||||
|
||||
bool _notificationsEnabled = true;
|
||||
bool get notificationsEnabled => _notificationsEnabled;
|
||||
|
||||
int _pollIntervalMinutes = 15;
|
||||
int get pollIntervalMinutes => _pollIntervalMinutes;
|
||||
|
||||
bool _notificationsIntroSeen = false;
|
||||
bool get notificationsIntroSeen => _notificationsIntroSeen;
|
||||
|
||||
Future<void> load() async {
|
||||
final value = await _storage.read(key: _lastHouseKey);
|
||||
if (value != null) {
|
||||
_lastHouseId = int.tryParse(value);
|
||||
final lastHouse = await _storage.read(key: _lastHouseKey);
|
||||
if (lastHouse != null) _lastHouseId = int.tryParse(lastHouse);
|
||||
|
||||
final notif = await _storage.read(key: _notificationsEnabledKey);
|
||||
if (notif != null) _notificationsEnabled = notif == 'true';
|
||||
|
||||
final poll = await _storage.read(key: _pollIntervalMinutesKey);
|
||||
if (poll != null) {
|
||||
final parsed = int.tryParse(poll);
|
||||
if (parsed != null && parsed > 0) _pollIntervalMinutes = parsed;
|
||||
}
|
||||
|
||||
final intro = await _storage.read(key: _notificationsIntroSeenKey);
|
||||
if (intro != null) _notificationsIntroSeen = intro == 'true';
|
||||
}
|
||||
|
||||
Future<void> setLastHouseId(int id) async {
|
||||
@@ -22,8 +44,38 @@ class PrefsService {
|
||||
await _storage.write(key: _lastHouseKey, value: id.toString());
|
||||
}
|
||||
|
||||
Future<void> setNotificationsEnabled(bool value) async {
|
||||
_notificationsEnabled = value;
|
||||
await _storage.write(
|
||||
key: _notificationsEnabledKey,
|
||||
value: value.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setPollIntervalMinutes(int minutes) async {
|
||||
_pollIntervalMinutes = minutes;
|
||||
await _storage.write(
|
||||
key: _pollIntervalMinutesKey,
|
||||
value: minutes.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setNotificationsIntroSeen(bool value) async {
|
||||
_notificationsIntroSeen = value;
|
||||
await _storage.write(
|
||||
key: _notificationsIntroSeenKey,
|
||||
value: value.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
_lastHouseId = null;
|
||||
_notificationsEnabled = true;
|
||||
_pollIntervalMinutes = 15;
|
||||
_notificationsIntroSeen = false;
|
||||
await _storage.delete(key: _lastHouseKey);
|
||||
await _storage.delete(key: _notificationsEnabledKey);
|
||||
await _storage.delete(key: _pollIntervalMinutesKey);
|
||||
await _storage.delete(key: _notificationsIntroSeenKey);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
const _iconMap = <String, IconData>{
|
||||
const checklistIconMap = <String, IconData>{
|
||||
'clipboard-check': Icons.assignment_turned_in,
|
||||
'clipboard-list': Icons.assignment,
|
||||
'format-list-checks': Icons.checklist,
|
||||
@@ -44,5 +44,5 @@ const _iconMap = <String, IconData>{
|
||||
const defaultChecklistIcon = Icons.assignment_turned_in;
|
||||
|
||||
IconData checklistIcon(String? key) {
|
||||
return _iconMap[key ?? ''] ?? defaultChecklistIcon;
|
||||
return checklistIconMap[key ?? ''] ?? defaultChecklistIcon;
|
||||
}
|
||||
|
||||
@@ -151,6 +151,7 @@ class _CategoriesViewState extends State<CategoriesView> {
|
||||
: RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.only(bottom: 96),
|
||||
itemCount: _categories.length,
|
||||
itemBuilder: (context, index) {
|
||||
final cat = _categories[index];
|
||||
|
||||
@@ -16,6 +16,7 @@ class ChecklistItemTile extends StatelessWidget {
|
||||
final ValueChanged<ListItem> onToggle;
|
||||
final ValueChanged<ListItem> onView;
|
||||
final ValueChanged<ListItem> onEdit;
|
||||
final ValueChanged<ListItem> onMove;
|
||||
final ValueChanged<ListItem> onDelete;
|
||||
|
||||
const ChecklistItemTile({
|
||||
@@ -26,6 +27,7 @@ class ChecklistItemTile extends StatelessWidget {
|
||||
required this.onToggle,
|
||||
required this.onView,
|
||||
required this.onEdit,
|
||||
required this.onMove,
|
||||
required this.onDelete,
|
||||
});
|
||||
|
||||
@@ -93,7 +95,12 @@ class ChecklistItemTile extends StatelessWidget {
|
||||
constraints: const BoxConstraints(),
|
||||
onPressed: () => onView(item),
|
||||
),
|
||||
_MoreMenuButton(item: item, onEdit: onEdit, onDelete: onDelete),
|
||||
_MoreMenuButton(
|
||||
item: item,
|
||||
onEdit: onEdit,
|
||||
onMove: onMove,
|
||||
onDelete: onDelete,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -252,11 +259,13 @@ class _Badge extends StatelessWidget {
|
||||
class _MoreMenuButton extends StatelessWidget {
|
||||
final ListItem item;
|
||||
final ValueChanged<ListItem> onEdit;
|
||||
final ValueChanged<ListItem> onMove;
|
||||
final ValueChanged<ListItem> onDelete;
|
||||
|
||||
const _MoreMenuButton({
|
||||
required this.item,
|
||||
required this.onEdit,
|
||||
required this.onMove,
|
||||
required this.onDelete,
|
||||
});
|
||||
|
||||
@@ -281,6 +290,16 @@ class _MoreMenuButton extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'move',
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.drive_file_move_outlined, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Text(m.checklists.moveItem),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'remove',
|
||||
child: Row(
|
||||
@@ -296,6 +315,8 @@ class _MoreMenuButton extends StatelessWidget {
|
||||
switch (value) {
|
||||
case 'edit':
|
||||
onEdit(item);
|
||||
case 'move':
|
||||
onMove(item);
|
||||
case 'remove':
|
||||
onDelete(item);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:pantry/i18n.dart';
|
||||
import 'package:pantry/models/category.dart' as models;
|
||||
@@ -167,8 +169,9 @@ class ChecklistsController extends ChangeNotifier {
|
||||
_checklistService.cache.set('sortBy', sort);
|
||||
notifyListeners();
|
||||
|
||||
// Persist to server
|
||||
_checklistService.setItemSortPref(houseId, sort);
|
||||
// Fire-and-forget the server persist so a slow or failing pref write
|
||||
// never blocks the item reload.
|
||||
unawaited(_persistSortPref(sort));
|
||||
|
||||
// Reload items with new sort
|
||||
if (_currentList != null) {
|
||||
@@ -177,6 +180,14 @@ class ChecklistsController extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _persistSortPref(String sort) async {
|
||||
try {
|
||||
await _checklistService.setItemSortPref(houseId, sort);
|
||||
} catch (e) {
|
||||
debugPrint('[ChecklistsController] Failed to persist sort pref: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> reorderItems(
|
||||
List<ListItem> partition,
|
||||
int oldIndex,
|
||||
@@ -218,6 +229,35 @@ class ChecklistsController extends ChangeNotifier {
|
||||
_checklistService.invalidateItems(keepListId: _currentList?.id);
|
||||
}
|
||||
|
||||
Future<ChecklistList> createList({
|
||||
required String name,
|
||||
String? description,
|
||||
String? icon,
|
||||
}) async {
|
||||
final list = await _checklistService.createList(
|
||||
houseId,
|
||||
name: name,
|
||||
description: description,
|
||||
icon: icon,
|
||||
);
|
||||
_lists = [..._lists, list];
|
||||
_checklistService.cacheLists(houseId, _lists);
|
||||
notifyListeners();
|
||||
return list;
|
||||
}
|
||||
|
||||
Future<void> moveItem(ListItem item, int targetListId) async {
|
||||
await _checklistService.moveItem(
|
||||
houseId,
|
||||
item.listId,
|
||||
item.id,
|
||||
targetListId: targetListId,
|
||||
);
|
||||
_items.removeWhere((i) => i.id == item.id);
|
||||
_checklistService.cacheItems(_currentList!.id, List.of(_items));
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<ListItem> addItem({
|
||||
required String name,
|
||||
String? description,
|
||||
|
||||
@@ -3,8 +3,10 @@ import 'package:pantry/i18n.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:pantry/models/checklist.dart';
|
||||
import 'package:pantry/utils/checklist_icons.dart';
|
||||
import 'package:pantry/widgets/checklist_selector.dart';
|
||||
import 'package:pantry/widgets/checklist_sort_button.dart';
|
||||
import 'package:pantry/widgets/create_list_dialog.dart';
|
||||
import 'checklist_item_tile.dart';
|
||||
import 'checklists_controller.dart';
|
||||
import 'item_detail_view.dart';
|
||||
@@ -194,6 +196,7 @@ class _ItemList extends StatelessWidget {
|
||||
),
|
||||
_ReorderablePartition(items: checked, controller: controller),
|
||||
],
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 88)),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -224,6 +227,80 @@ class _ReorderablePartition extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
void _moveItem(
|
||||
BuildContext context,
|
||||
ChecklistsController controller,
|
||||
ListItem item,
|
||||
) {
|
||||
final otherLists = controller.lists
|
||||
.where((l) => l.id != controller.currentList?.id)
|
||||
.toList();
|
||||
|
||||
showDialog<int>(
|
||||
context: context,
|
||||
builder: (ctx) => SimpleDialog(
|
||||
title: Text(m.checklists.moveItem),
|
||||
children: [
|
||||
...otherLists.map(
|
||||
(list) => SimpleDialogOption(
|
||||
onPressed: () => Navigator.pop(ctx, list.id),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(checklistIcon(list.icon), size: 20),
|
||||
const SizedBox(width: 12),
|
||||
Text(list.name),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
SimpleDialogOption(
|
||||
onPressed: () async {
|
||||
Navigator.pop(ctx);
|
||||
final created = await showCreateListDialog(context, controller);
|
||||
if (created != null && context.mounted) {
|
||||
try {
|
||||
await controller.moveItem(item, created.id);
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(m.checklists.moveFailed)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.add,
|
||||
size: 20,
|
||||
color: Theme.of(ctx).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
m.checklists.createList,
|
||||
style: TextStyle(color: Theme.of(ctx).colorScheme.primary),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
).then((targetListId) async {
|
||||
if (targetListId == null) return;
|
||||
try {
|
||||
await controller.moveItem(item, targetListId);
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(m.checklists.moveFailed)));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _editItem(
|
||||
BuildContext context,
|
||||
ChecklistsController controller,
|
||||
@@ -292,6 +369,7 @@ class _ReorderablePartition extends StatelessWidget {
|
||||
onToggle: controller.toggleItem,
|
||||
onView: (item) => _viewItem(context, controller, item),
|
||||
onEdit: (item) => _editItem(context, controller, item),
|
||||
onMove: (item) => _moveItem(context, controller, item),
|
||||
onDelete: (item) => _deleteItem(context, controller, item),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -5,9 +5,15 @@ import 'package:pantry/i18n.dart';
|
||||
import 'package:pantry/views/categories/categories_view.dart';
|
||||
import 'package:pantry/views/checklists/checklists_view.dart';
|
||||
import 'package:pantry/views/notes/notes_wall_view.dart';
|
||||
import 'package:pantry/models/house.dart';
|
||||
import 'package:pantry/services/deep_link_service.dart';
|
||||
import 'package:pantry/views/notifications/notifications_controller.dart';
|
||||
import 'package:pantry/views/notifications/notifications_view.dart';
|
||||
import 'package:pantry/views/photos/photo_board_view.dart';
|
||||
import 'package:pantry/views/settings/settings_view.dart';
|
||||
import 'package:pantry/widgets/create_house_dialog.dart';
|
||||
import 'package:pantry/widgets/no_houses_view.dart';
|
||||
import 'package:pantry/widgets/notifications_bell.dart';
|
||||
import 'package:pantry/widgets/server_app_missing_view.dart';
|
||||
import 'package:pantry/widgets/user_menu_button.dart';
|
||||
import 'home_controller.dart';
|
||||
@@ -54,8 +60,79 @@ class _HomeViewBody extends StatefulWidget {
|
||||
State<_HomeViewBody> createState() => _HomeViewBodyState();
|
||||
}
|
||||
|
||||
class _HomeViewBodyState extends State<_HomeViewBody> {
|
||||
class _HomeViewBodyState extends State<_HomeViewBody>
|
||||
with WidgetsBindingObserver {
|
||||
int _tabIndex = 0;
|
||||
final _pageController = PageController();
|
||||
final _notificationsController = NotificationsController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_notificationsController.load();
|
||||
|
||||
// Consume any deep link that arrived before we mounted (e.g. from a
|
||||
// cold-start notification tap).
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_consumePendingDeepLink();
|
||||
});
|
||||
|
||||
// Listen for deep links that arrive while the home view is mounted
|
||||
// (notification tapped while app is in foreground or background).
|
||||
DeepLinkService.instance.pending.addListener(_consumePendingDeepLink);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
DeepLinkService.instance.pending.removeListener(_consumePendingDeepLink);
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_pageController.dispose();
|
||||
_notificationsController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
_notificationsController.refresh();
|
||||
_consumePendingDeepLink();
|
||||
}
|
||||
}
|
||||
|
||||
void _goToTab(int index) {
|
||||
if (index == _tabIndex) return;
|
||||
_pageController.animateToPage(
|
||||
index,
|
||||
duration: const Duration(milliseconds: 280),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
|
||||
void _consumePendingDeepLink() {
|
||||
final link = DeepLinkService.instance.consume();
|
||||
if (link == null) return;
|
||||
final homeController = context.read<HomeController>();
|
||||
|
||||
// Switch house if specified and different from current.
|
||||
if (link.houseId != null &&
|
||||
link.houseId != homeController.currentHouse?.id) {
|
||||
final house = homeController.houses.cast<House?>().firstWhere(
|
||||
(h) => h!.id == link.houseId,
|
||||
orElse: () => null,
|
||||
);
|
||||
if (house != null) {
|
||||
homeController.selectHouse(house);
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
if (_pageController.hasClients) {
|
||||
_goToTab(link.tabIndex);
|
||||
} else {
|
||||
setState(() => _tabIndex = link.tabIndex);
|
||||
}
|
||||
}
|
||||
|
||||
String get _tabTitle => switch (_tabIndex) {
|
||||
0 => m.nav.checklists,
|
||||
@@ -86,32 +163,40 @@ class _HomeViewBodyState extends State<_HomeViewBody> {
|
||||
);
|
||||
},
|
||||
),
|
||||
NotificationsBell(
|
||||
controller: _notificationsController,
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) =>
|
||||
NotificationsView(controller: _notificationsController),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
UserMenuButton(
|
||||
houses: controller.houses,
|
||||
currentHouse: controller.currentHouse,
|
||||
onHouseSelected: controller.selectHouse,
|
||||
onCreateHouse: () => showCreateHouseDialog(context, controller),
|
||||
onOpenSettings: () {
|
||||
Navigator.of(
|
||||
context,
|
||||
).push(MaterialPageRoute(builder: (_) => const SettingsView()));
|
||||
},
|
||||
onLogout: widget.onLogout,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _buildBody(controller),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: _tabIndex,
|
||||
onDestinationSelected: (i) => setState(() => _tabIndex = i),
|
||||
bottomNavigationBar: _AnimatedBottomNav(
|
||||
pageController: _pageController,
|
||||
currentIndex: _tabIndex,
|
||||
onTap: _goToTab,
|
||||
destinations: [
|
||||
NavigationDestination(
|
||||
icon: const Icon(Icons.assignment_turned_in),
|
||||
label: m.nav.checklists,
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: const Icon(Icons.photo),
|
||||
label: m.nav.photoBoard,
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: const Icon(Icons.insert_drive_file),
|
||||
label: m.nav.notesWall,
|
||||
),
|
||||
(icon: Icons.assignment_turned_in, label: m.nav.checklists),
|
||||
(icon: Icons.photo, label: m.nav.photoBoard),
|
||||
(icon: Icons.insert_drive_file, label: m.nav.notesWall),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -150,21 +235,125 @@ class _HomeViewBodyState extends State<_HomeViewBody> {
|
||||
}
|
||||
|
||||
final houseId = controller.currentHouse!.id;
|
||||
switch (_tabIndex) {
|
||||
case 0:
|
||||
return ChecklistsView(
|
||||
key: ValueKey('checklists-$houseId'),
|
||||
houseId: houseId,
|
||||
);
|
||||
case 1:
|
||||
return PhotoBoardView(
|
||||
key: ValueKey('photos-$houseId'),
|
||||
houseId: houseId,
|
||||
);
|
||||
case 2:
|
||||
return NotesWallView(key: ValueKey('notes-$houseId'), houseId: houseId);
|
||||
default:
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return PageView(
|
||||
controller: _pageController,
|
||||
physics: const ClampingScrollPhysics(),
|
||||
onPageChanged: (i) => setState(() => _tabIndex = i),
|
||||
children: [
|
||||
ChecklistsView(key: ValueKey('checklists-$houseId'), houseId: houseId),
|
||||
PhotoBoardView(key: ValueKey('photos-$houseId'), houseId: houseId),
|
||||
NotesWallView(key: ValueKey('notes-$houseId'), houseId: houseId),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
typedef _NavDestination = ({IconData icon, String label});
|
||||
|
||||
/// Bottom navigation bar that continuously interpolates its indicator
|
||||
/// and icon colors based on a [PageController]'s fractional page value.
|
||||
class _AnimatedBottomNav extends StatelessWidget {
|
||||
final PageController pageController;
|
||||
final int currentIndex;
|
||||
final ValueChanged<int> onTap;
|
||||
final List<_NavDestination> destinations;
|
||||
|
||||
const _AnimatedBottomNav({
|
||||
required this.pageController,
|
||||
required this.currentIndex,
|
||||
required this.onTap,
|
||||
required this.destinations,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final cs = theme.colorScheme;
|
||||
|
||||
return Material(
|
||||
color: cs.surface,
|
||||
elevation: 3,
|
||||
surfaceTintColor: cs.surfaceTint,
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: SizedBox(
|
||||
height: 72,
|
||||
child: AnimatedBuilder(
|
||||
animation: pageController,
|
||||
builder: (context, _) {
|
||||
final page = pageController.hasClients
|
||||
? (pageController.page ?? currentIndex.toDouble())
|
||||
: currentIndex.toDouble();
|
||||
return Row(
|
||||
children: List.generate(destinations.length, (i) {
|
||||
final d = destinations[i];
|
||||
final distance = (page - i).abs().clamp(0.0, 1.0);
|
||||
final t = 1.0 - distance;
|
||||
final iconColor = Color.lerp(
|
||||
cs.onSurfaceVariant,
|
||||
cs.onSecondaryContainer,
|
||||
t,
|
||||
)!;
|
||||
final labelColor = Color.lerp(
|
||||
cs.onSurfaceVariant,
|
||||
cs.onSurface,
|
||||
t,
|
||||
)!;
|
||||
return Expanded(
|
||||
child: InkWell(
|
||||
onTap: () => onTap(i),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_AnimatedIndicator(
|
||||
opacity: t,
|
||||
color: cs.secondaryContainer,
|
||||
child: Icon(d.icon, color: iconColor, size: 24),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
d.label,
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: labelColor,
|
||||
fontWeight: t > 0.5
|
||||
? FontWeight.w600
|
||||
: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AnimatedIndicator extends StatelessWidget {
|
||||
final double opacity;
|
||||
final Color color;
|
||||
final Widget child;
|
||||
|
||||
const _AnimatedIndicator({
|
||||
required this.opacity,
|
||||
required this.color,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: opacity),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,7 +138,7 @@ class _NotesGrid extends StatelessWidget {
|
||||
}
|
||||
|
||||
return GridView.builder(
|
||||
padding: const EdgeInsets.all(8),
|
||||
padding: const EdgeInsets.fromLTRB(8, 8, 8, 96),
|
||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 220,
|
||||
mainAxisSpacing: 8,
|
||||
|
||||
81
lib/views/notifications/notifications_controller.dart
Normal file
@@ -0,0 +1,81 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:pantry/models/notification.dart';
|
||||
import 'package:pantry/services/background_notification_task.dart';
|
||||
import 'package:pantry/services/notification_service.dart';
|
||||
|
||||
class NotificationsController extends ChangeNotifier {
|
||||
NotificationsController();
|
||||
|
||||
List<NcNotification> _notifications = [];
|
||||
List<NcNotification> get notifications => _notifications;
|
||||
|
||||
int get unreadCount => _notifications.length;
|
||||
|
||||
bool _isLoading = false;
|
||||
bool get isLoading => _isLoading;
|
||||
|
||||
String? _error;
|
||||
String? get error => _error;
|
||||
|
||||
Future<void> load() async {
|
||||
_error = null;
|
||||
if (_notifications.isEmpty) {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
try {
|
||||
_notifications = await NotificationService.instance.getNotifications();
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
await _markAllSeen();
|
||||
} catch (e) {
|
||||
debugPrint('[NotificationsController] Failed to load: $e');
|
||||
_isLoading = false;
|
||||
_error = e.toString();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
try {
|
||||
_notifications = await NotificationService.instance.getNotifications();
|
||||
notifyListeners();
|
||||
await _markAllSeen();
|
||||
} catch (e) {
|
||||
debugPrint('[NotificationsController] Failed to refresh: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _markAllSeen() async {
|
||||
final ids = _notifications.map((n) => n.notificationId).toList();
|
||||
try {
|
||||
await markCurrentNotificationsAsSeen(ids);
|
||||
} catch (e) {
|
||||
debugPrint('[NotificationsController] Failed to mark seen: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> dismiss(NcNotification notification) async {
|
||||
_notifications = _notifications
|
||||
.where((n) => n.notificationId != notification.notificationId)
|
||||
.toList();
|
||||
notifyListeners();
|
||||
try {
|
||||
await NotificationService.instance.dismiss(notification.notificationId);
|
||||
} catch (e) {
|
||||
debugPrint('[NotificationsController] Failed to dismiss: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> dismissAll() async {
|
||||
final ids = _notifications.map((n) => n.notificationId).toList();
|
||||
_notifications = [];
|
||||
notifyListeners();
|
||||
try {
|
||||
await NotificationService.instance.dismissAll(ids);
|
||||
} catch (e) {
|
||||
debugPrint('[NotificationsController] Failed to dismiss all: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
189
lib/views/notifications/notifications_view.dart
Normal file
@@ -0,0 +1,189 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:pantry/i18n.dart';
|
||||
import 'package:pantry/models/notification.dart';
|
||||
import 'package:pantry/services/deep_link_service.dart';
|
||||
import 'notifications_controller.dart';
|
||||
|
||||
class NotificationsView extends StatefulWidget {
|
||||
final NotificationsController controller;
|
||||
|
||||
const NotificationsView({super.key, required this.controller});
|
||||
|
||||
@override
|
||||
State<NotificationsView> createState() => _NotificationsViewState();
|
||||
}
|
||||
|
||||
class _NotificationsViewState extends State<NotificationsView> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.controller.refresh();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ChangeNotifierProvider.value(
|
||||
value: widget.controller,
|
||||
child: const _NotificationsBody(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NotificationsBody extends StatelessWidget {
|
||||
const _NotificationsBody();
|
||||
|
||||
void _openNotification(BuildContext context, NcNotification n) {
|
||||
final target = n.target;
|
||||
if (target == null) return;
|
||||
final tab = switch (target) {
|
||||
NotificationTarget.checklists => 0,
|
||||
NotificationTarget.photos => 1,
|
||||
NotificationTarget.notes => 2,
|
||||
};
|
||||
DeepLinkService.instance.push(DeepLink(tabIndex: tab, houseId: n.houseId));
|
||||
// Pop back to home so it consumes the deep link.
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final controller = context.watch<NotificationsController>();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(m.notifications.title),
|
||||
actions: [
|
||||
if (controller.notifications.isNotEmpty)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.done_all),
|
||||
tooltip: m.notifications.dismissAll,
|
||||
onPressed: controller.dismissAll,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _buildBody(context, controller),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody(BuildContext context, NotificationsController controller) {
|
||||
if (controller.isLoading && controller.notifications.isEmpty) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (controller.error != null && controller.notifications.isEmpty) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(m.notifications.failedToLoad, textAlign: TextAlign.center),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
onPressed: controller.load,
|
||||
child: Text(m.common.retry),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (controller.notifications.isEmpty) {
|
||||
return RefreshIndicator(
|
||||
onRefresh: controller.refresh,
|
||||
child: ListView(
|
||||
children: [
|
||||
const SizedBox(height: 100),
|
||||
Center(child: Text(m.notifications.empty)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: controller.refresh,
|
||||
child: ListView.separated(
|
||||
itemCount: controller.notifications.length,
|
||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final n = controller.notifications[index];
|
||||
return _NotificationTile(
|
||||
notification: n,
|
||||
onDismiss: () => controller.dismiss(n),
|
||||
onTap: () => _openNotification(context, n),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NotificationTile extends StatelessWidget {
|
||||
final NcNotification notification;
|
||||
final VoidCallback onDismiss;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _NotificationTile({
|
||||
required this.notification,
|
||||
required this.onDismiss,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Dismissible(
|
||||
key: ValueKey(notification.notificationId),
|
||||
direction: DismissDirection.endToStart,
|
||||
onDismissed: (_) => onDismiss(),
|
||||
background: Container(
|
||||
color: theme.colorScheme.errorContainer,
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Icon(Icons.delete, color: theme.colorScheme.onErrorContainer),
|
||||
),
|
||||
child: ListTile(
|
||||
onTap: notification.target != null ? onTap : null,
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: theme.colorScheme.primaryContainer,
|
||||
child: Icon(
|
||||
Icons.notifications,
|
||||
color: theme.colorScheme.onPrimaryContainer,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
notification.subject,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: notification.message.isNotEmpty
|
||||
? Text(
|
||||
notification.message,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
)
|
||||
: null,
|
||||
trailing: Text(
|
||||
_formatRelative(notification.parsedDatetime),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatRelative(DateTime? dt) {
|
||||
if (dt == null) return '';
|
||||
final diff = DateTime.now().toUtc().difference(dt.toUtc());
|
||||
if (diff.inMinutes < 1) return m.notifications.justNow;
|
||||
if (diff.inHours < 1) return m.notifications.minutesAgo(diff.inMinutes);
|
||||
if (diff.inDays < 1) return m.notifications.hoursAgo(diff.inHours);
|
||||
return m.notifications.daysAgo(diff.inDays);
|
||||
}
|
||||
}
|
||||
172
lib/views/notifications_intro/notifications_intro_view.dart
Normal file
@@ -0,0 +1,172 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pantry/i18n.dart';
|
||||
import 'package:pantry/services/background_notification_task.dart';
|
||||
import 'package:pantry/services/local_notifications_service.dart';
|
||||
import 'package:pantry/services/prefs_service.dart';
|
||||
|
||||
class NotificationsIntroView extends StatefulWidget {
|
||||
final VoidCallback onDone;
|
||||
|
||||
const NotificationsIntroView({super.key, required this.onDone});
|
||||
|
||||
@override
|
||||
State<NotificationsIntroView> createState() => _NotificationsIntroViewState();
|
||||
}
|
||||
|
||||
class _NotificationsIntroViewState extends State<NotificationsIntroView> {
|
||||
bool _working = false;
|
||||
|
||||
Future<void> _enable() async {
|
||||
setState(() => _working = true);
|
||||
final granted = await LocalNotificationsService.instance
|
||||
.requestPermission();
|
||||
if (!mounted) return;
|
||||
|
||||
if (!granted) {
|
||||
setState(() => _working = false);
|
||||
await _showPermissionDeniedDialog();
|
||||
await _complete(enabled: false);
|
||||
return;
|
||||
}
|
||||
|
||||
await PrefsService.instance.setNotificationsEnabled(true);
|
||||
await registerBackgroundNotificationPoll();
|
||||
await _complete(enabled: true);
|
||||
}
|
||||
|
||||
Future<void> _skip() async {
|
||||
await PrefsService.instance.setNotificationsEnabled(false);
|
||||
await _complete(enabled: false);
|
||||
}
|
||||
|
||||
Future<void> _complete({required bool enabled}) async {
|
||||
await PrefsService.instance.setNotificationsIntroSeen(true);
|
||||
if (mounted) widget.onDone();
|
||||
}
|
||||
|
||||
Future<void> _showPermissionDeniedDialog() async {
|
||||
final intro = m.notificationsIntro;
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(intro.permissionDeniedTitle),
|
||||
content: Text(intro.permissionDeniedBody),
|
||||
actions: [
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: Text(intro.ok),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final intro = m.notificationsIntro;
|
||||
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(32, 48, 32, 32),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 100,
|
||||
height: 100,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primaryContainer,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.notifications_active_outlined,
|
||||
size: 56,
|
||||
color: theme.colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Text(
|
||||
intro.title,
|
||||
style: theme.textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
intro.body,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
_Bullet(icon: Icons.group_outlined, text: intro.bullet1),
|
||||
const SizedBox(height: 12),
|
||||
_Bullet(icon: Icons.dns_outlined, text: intro.bullet2),
|
||||
const SizedBox(height: 12),
|
||||
_Bullet(
|
||||
icon: Icons.schedule_outlined,
|
||||
text: intro.bullet3,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton.icon(
|
||||
onPressed: _working ? null : _enable,
|
||||
icon: _working
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.notifications_active),
|
||||
label: Text(intro.enableButton),
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: _working ? null : _skip,
|
||||
style: TextButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
),
|
||||
child: Text(intro.skipButton),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Bullet extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String text;
|
||||
|
||||
const _Bullet({required this.icon, required this.text});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Row(
|
||||
children: [
|
||||
Icon(icon, size: 22, color: theme.colorScheme.primary),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Text(text, style: theme.textTheme.bodyMedium)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -73,25 +73,36 @@ class _PhotoBoardBody extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
_TopBar(controller: controller),
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: controller.refresh,
|
||||
child: _PhotoGrid(controller: controller),
|
||||
final inFolder = controller.currentFolderId != null;
|
||||
|
||||
return PopScope(
|
||||
canPop: !inFolder,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (didPop) return;
|
||||
if (controller.currentFolderId != null) {
|
||||
controller.exitFolder();
|
||||
}
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
_TopBar(controller: controller),
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: controller.refresh,
|
||||
child: _PhotoGrid(controller: controller),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Positioned(
|
||||
right: 16,
|
||||
bottom: 16,
|
||||
child: PhotoAddButton(controller: controller),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
Positioned(
|
||||
right: 16,
|
||||
bottom: 16,
|
||||
child: PhotoAddButton(controller: controller),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -188,7 +199,7 @@ class _PhotoGrid extends StatelessWidget {
|
||||
}
|
||||
|
||||
return GridView.builder(
|
||||
padding: const EdgeInsets.all(8),
|
||||
padding: const EdgeInsets.fromLTRB(8, 8, 8, 96),
|
||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 180,
|
||||
mainAxisSpacing: 8,
|
||||
|
||||
115
lib/views/settings/settings_view.dart
Normal file
@@ -0,0 +1,115 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pantry/i18n.dart';
|
||||
import 'package:pantry/services/background_notification_task.dart';
|
||||
import 'package:pantry/services/local_notifications_service.dart';
|
||||
import 'package:pantry/services/prefs_service.dart';
|
||||
|
||||
class SettingsView extends StatefulWidget {
|
||||
const SettingsView({super.key});
|
||||
|
||||
@override
|
||||
State<SettingsView> createState() => _SettingsViewState();
|
||||
}
|
||||
|
||||
class _SettingsViewState extends State<SettingsView> {
|
||||
late bool _notificationsEnabled;
|
||||
late int _pollIntervalMinutes;
|
||||
|
||||
static const _pollOptions = [15, 30, 60, 120, 360];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_notificationsEnabled = PrefsService.instance.notificationsEnabled;
|
||||
_pollIntervalMinutes = PrefsService.instance.pollIntervalMinutes;
|
||||
}
|
||||
|
||||
Future<void> _toggleNotifications(bool value) async {
|
||||
if (value) {
|
||||
final granted = await LocalNotificationsService.instance
|
||||
.requestPermission();
|
||||
if (!granted) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(m.settings.permissionDenied)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await PrefsService.instance.setNotificationsEnabled(value);
|
||||
setState(() => _notificationsEnabled = value);
|
||||
|
||||
if (value) {
|
||||
await registerBackgroundNotificationPoll();
|
||||
} else {
|
||||
await cancelBackgroundNotificationPoll();
|
||||
await LocalNotificationsService.instance.cancelAll();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _setPollInterval(int? minutes) async {
|
||||
if (minutes == null || minutes == _pollIntervalMinutes) return;
|
||||
await PrefsService.instance.setPollIntervalMinutes(minutes);
|
||||
setState(() => _pollIntervalMinutes = minutes);
|
||||
if (_notificationsEnabled) {
|
||||
await rescheduleBackgroundNotificationPoll();
|
||||
}
|
||||
}
|
||||
|
||||
String _pollIntervalLabel(int minutes) => switch (minutes) {
|
||||
15 => m.settings.pollInterval15m,
|
||||
30 => m.settings.pollInterval30m,
|
||||
60 => m.settings.pollInterval1h,
|
||||
120 => m.settings.pollInterval2h,
|
||||
360 => m.settings.pollInterval6h,
|
||||
_ => '$minutes min',
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(m.settings.title)),
|
||||
body: ListView(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Text(
|
||||
m.settings.notificationsSection,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
SwitchListTile(
|
||||
title: Text(m.settings.enableNotifications),
|
||||
subtitle: Text(m.settings.enableNotificationsBody),
|
||||
value: _notificationsEnabled,
|
||||
onChanged: _toggleNotifications,
|
||||
),
|
||||
ListTile(
|
||||
enabled: _notificationsEnabled,
|
||||
title: Text(m.settings.pollInterval),
|
||||
subtitle: Text(_pollIntervalLabel(_pollIntervalMinutes)),
|
||||
trailing: DropdownButton<int>(
|
||||
value: _pollIntervalMinutes,
|
||||
onChanged: _notificationsEnabled ? _setPollInterval : null,
|
||||
items: [
|
||||
for (final minutes in _pollOptions)
|
||||
DropdownMenuItem(
|
||||
value: minutes,
|
||||
child: Text(_pollIntervalLabel(minutes)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ class ChecklistSortButton extends StatelessWidget {
|
||||
'oldest',
|
||||
'name_asc',
|
||||
'name_desc',
|
||||
'category',
|
||||
'custom',
|
||||
];
|
||||
|
||||
@@ -25,6 +26,7 @@ class ChecklistSortButton extends StatelessWidget {
|
||||
'oldest' => m.checklists.sort.oldestFirst,
|
||||
'name_asc' => m.checklists.sort.nameAZ,
|
||||
'name_desc' => m.checklists.sort.nameZA,
|
||||
'category' => m.checklists.sort.category,
|
||||
'custom' => m.checklists.sort.custom,
|
||||
_ => key,
|
||||
};
|
||||
|
||||
152
lib/widgets/create_list_dialog.dart
Normal file
@@ -0,0 +1,152 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:pantry/i18n.dart';
|
||||
import 'package:pantry/models/checklist.dart';
|
||||
import 'package:pantry/utils/checklist_icons.dart';
|
||||
import 'package:pantry/views/checklists/checklists_controller.dart';
|
||||
|
||||
/// Shows a dialog to create a new checklist. Returns the created
|
||||
/// [ChecklistList] on success, or null if cancelled.
|
||||
Future<ChecklistList?> showCreateListDialog(
|
||||
BuildContext context,
|
||||
ChecklistsController controller,
|
||||
) {
|
||||
return showDialog<ChecklistList>(
|
||||
context: context,
|
||||
builder: (_) => CreateListDialog(controller: controller),
|
||||
);
|
||||
}
|
||||
|
||||
class CreateListDialog extends StatefulWidget {
|
||||
final ChecklistsController controller;
|
||||
|
||||
const CreateListDialog({super.key, required this.controller});
|
||||
|
||||
@override
|
||||
State<CreateListDialog> createState() => _CreateListDialogState();
|
||||
}
|
||||
|
||||
class _CreateListDialogState extends State<CreateListDialog> {
|
||||
final _nameController = TextEditingController();
|
||||
final _descriptionController = TextEditingController();
|
||||
String _selectedIcon = 'clipboard-check';
|
||||
bool _saving = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_descriptionController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
final name = _nameController.text.trim();
|
||||
if (name.isEmpty) return;
|
||||
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
final list = await widget.controller.createList(
|
||||
name: name,
|
||||
description: _descriptionController.text.trim(),
|
||||
icon: _selectedIcon,
|
||||
);
|
||||
if (mounted) Navigator.of(context).pop(list);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(m.checklists.createListFailed)));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return AlertDialog(
|
||||
title: Text(m.checklists.createList),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextField(
|
||||
controller: _nameController,
|
||||
autofocus: true,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
decoration: InputDecoration(
|
||||
labelText: m.checklists.listName,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _descriptionController,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
decoration: InputDecoration(
|
||||
labelText: m.checklists.listDescription,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
maxLines: 2,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(m.checklists.listIcon, style: theme.textTheme.bodyMedium),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 4,
|
||||
runSpacing: 4,
|
||||
children: checklistIconMap.entries.map((entry) {
|
||||
final isSelected = _selectedIcon == entry.key;
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _selectedIcon = entry.key),
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? theme.colorScheme.primaryContainer
|
||||
: null,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: isSelected
|
||||
? Border.all(
|
||||
color: theme.colorScheme.primary,
|
||||
width: 2,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: Icon(
|
||||
entry.value,
|
||||
size: 20,
|
||||
color: isSelected
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _saving ? null : () => Navigator.pop(context),
|
||||
child: Text(m.common.cancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: _saving ? null : _save,
|
||||
child: _saving
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Text(m.common.save),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
69
lib/widgets/notifications_bell.dart
Normal file
@@ -0,0 +1,69 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:pantry/views/notifications/notifications_controller.dart';
|
||||
|
||||
class NotificationsBell extends StatelessWidget {
|
||||
final NotificationsController controller;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const NotificationsBell({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ChangeNotifierProvider.value(
|
||||
value: controller,
|
||||
child: Consumer<NotificationsController>(
|
||||
builder: (context, c, _) {
|
||||
final theme = Theme.of(context);
|
||||
final count = c.unreadCount;
|
||||
return IconButton(
|
||||
onPressed: onTap,
|
||||
icon: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
const Icon(Icons.notifications_outlined),
|
||||
if (count > 0)
|
||||
Positioned(
|
||||
right: -6,
|
||||
top: -4,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 5,
|
||||
vertical: 1,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.error,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.surface,
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 16,
|
||||
minHeight: 16,
|
||||
),
|
||||
child: Text(
|
||||
count > 99 ? '99+' : '$count',
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onError,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ class UserMenuButton extends StatelessWidget {
|
||||
final House? currentHouse;
|
||||
final ValueChanged<House> onHouseSelected;
|
||||
final VoidCallback onCreateHouse;
|
||||
final VoidCallback onOpenSettings;
|
||||
final VoidCallback onLogout;
|
||||
|
||||
const UserMenuButton({
|
||||
@@ -18,6 +19,7 @@ class UserMenuButton extends StatelessWidget {
|
||||
required this.currentHouse,
|
||||
required this.onHouseSelected,
|
||||
required this.onCreateHouse,
|
||||
required this.onOpenSettings,
|
||||
required this.onLogout,
|
||||
});
|
||||
|
||||
@@ -160,6 +162,16 @@ class UserMenuButton extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const PopupMenuDivider(),
|
||||
PopupMenuItem<String>(
|
||||
value: 'settings',
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.settings_outlined, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Text(m.settings.title),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem<String>(
|
||||
value: 'logout',
|
||||
child: Row(
|
||||
@@ -177,6 +189,8 @@ class UserMenuButton extends StatelessWidget {
|
||||
onHouseSelected(value);
|
||||
} else if (value == 'create_house') {
|
||||
onCreateHouse();
|
||||
} else if (value == 'settings') {
|
||||
onOpenSettings();
|
||||
} else if (value == 'logout') {
|
||||
onLogout();
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import file_selector_macos
|
||||
import flutter_local_notifications
|
||||
import flutter_secure_storage_darwin
|
||||
import package_info_plus
|
||||
import sqflite_darwin
|
||||
@@ -14,6 +15,7 @@ import wakelock_plus
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
||||
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
|
||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
||||
|
||||
64
pubspec.lock
@@ -350,6 +350,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
flutter_local_notifications:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_local_notifications
|
||||
sha256: ef41ae901e7529e52934feba19ed82827b11baa67336829564aeab3129460610
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "18.0.1"
|
||||
flutter_local_notifications_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_linux
|
||||
sha256: "8f685642876742c941b29c32030f6f4f6dacd0e4eaecb3efbb187d6a3812ca01"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.0.0"
|
||||
flutter_local_notifications_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_platform_interface
|
||||
sha256: "6c5b83c86bf819cdb177a9247a3722067dd8cc6313827ce7c77a4b238a26fd52"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.0.0"
|
||||
flutter_markdown_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1046,6 +1070,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.10"
|
||||
timezone:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: timezone
|
||||
sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.10.1"
|
||||
timing:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1238,6 +1270,38 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.15.0"
|
||||
workmanager:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: workmanager
|
||||
sha256: "065673b2a465865183093806925419d311a9a5e0995aa74ccf8920fd695e2d10"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.0+3"
|
||||
workmanager_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: workmanager_android
|
||||
sha256: "9ae744db4ef891f5fcd2fb8671fccc712f4f96489a487a1411e0c8675e5e8cb7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.0+2"
|
||||
workmanager_apple:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: workmanager_apple
|
||||
sha256: "1cc12ae3cbf5535e72f7ba4fde0c12dd11b757caf493a28e22d684052701f2ca"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.1+2"
|
||||
workmanager_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: workmanager_platform_interface
|
||||
sha256: f40422f10b970c67abb84230b44da22b075147637532ac501729256fcea10a47
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.1+1"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: pantry
|
||||
description: "A new Flutter project."
|
||||
description: "Manage your household with your Nextcloud — lists, photos & notes."
|
||||
# The following line prevents the package from being accidentally published to
|
||||
# pub.dev using `flutter pub publish`. This is preferred for private packages.
|
||||
publish_to: "none" # Remove this line if you wish to publish to pub.dev
|
||||
@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 0.1.0+2
|
||||
version: 0.3.0+5
|
||||
|
||||
environment:
|
||||
sdk: ^3.11.1
|
||||
@@ -43,8 +43,10 @@ dependencies:
|
||||
path_provider: ^2.1.5
|
||||
intl: ^0.20.2
|
||||
cached_network_image: ^3.4.1
|
||||
flutter_local_notifications: ^18.0.1
|
||||
flutter_markdown_plus: ^1.0.3
|
||||
image_picker: ^1.1.2
|
||||
workmanager: ^0.9.0+3
|
||||
i18n:
|
||||
git: https://github.com/chenasraf/i18n
|
||||
|
||||
|
||||
@@ -3,9 +3,11 @@ import 'dart:typed_data';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:pantry/models/house.dart';
|
||||
import 'package:pantry/models/note.dart';
|
||||
import 'package:pantry/models/notification.dart';
|
||||
import 'package:pantry/models/photo.dart';
|
||||
import 'package:pantry/views/home/home_controller.dart';
|
||||
import 'package:pantry/views/notes/notes_controller.dart';
|
||||
import 'package:pantry/views/notifications/notifications_controller.dart';
|
||||
import 'package:pantry/views/photos/photo_board_controller.dart';
|
||||
|
||||
/// A fake [PhotoBoardController] that does not touch any services.
|
||||
@@ -174,6 +176,65 @@ class FakeHomeController extends HomeController {
|
||||
}
|
||||
}
|
||||
|
||||
/// A fake [NotificationsController] that does not touch any services.
|
||||
class FakeNotificationsController extends NotificationsController {
|
||||
FakeNotificationsController({
|
||||
List<NcNotification>? notifications,
|
||||
bool isLoading = false,
|
||||
String? error,
|
||||
}) : _notifications = notifications ?? [],
|
||||
_isLoading = isLoading,
|
||||
_error = error;
|
||||
|
||||
final List<NcNotification> _notifications;
|
||||
@override
|
||||
List<NcNotification> get notifications => _notifications;
|
||||
|
||||
@override
|
||||
int get unreadCount => _notifications.length;
|
||||
|
||||
final bool _isLoading;
|
||||
@override
|
||||
bool get isLoading => _isLoading;
|
||||
|
||||
final String? _error;
|
||||
@override
|
||||
String? get error => _error;
|
||||
|
||||
int loadCalls = 0;
|
||||
int refreshCalls = 0;
|
||||
int dismissCalls = 0;
|
||||
int dismissAllCalls = 0;
|
||||
NcNotification? lastDismissed;
|
||||
|
||||
@override
|
||||
Future<void> load() async {
|
||||
loadCalls++;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> refresh() async {
|
||||
refreshCalls++;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dismiss(NcNotification notification) async {
|
||||
dismissCalls++;
|
||||
lastDismissed = notification;
|
||||
_notifications.removeWhere(
|
||||
(n) => n.notificationId == notification.notificationId,
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dismissAll() async {
|
||||
dismissAllCalls++;
|
||||
_notifications.clear();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a fake [UploadTask] for tests.
|
||||
UploadTask makeUploadTask({
|
||||
String fileName = 'photo.jpg',
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:pantry/models/category.dart';
|
||||
import 'package:pantry/models/checklist.dart';
|
||||
import 'package:pantry/models/house.dart';
|
||||
import 'package:pantry/models/note.dart';
|
||||
import 'package:pantry/models/notification.dart';
|
||||
import 'package:pantry/models/photo.dart';
|
||||
|
||||
const _now = 1700000000;
|
||||
@@ -124,6 +125,30 @@ ChecklistList makeChecklistList({
|
||||
updatedAt: updatedAt ?? _now,
|
||||
);
|
||||
|
||||
NcNotification makeNotification({
|
||||
int notificationId = 1,
|
||||
String app = 'pantry',
|
||||
String user = 'alice',
|
||||
String subject = 'alice added an item',
|
||||
String message = '',
|
||||
String? datetime,
|
||||
String objectType = 'item',
|
||||
String objectId = '1',
|
||||
String? icon,
|
||||
String? link,
|
||||
}) => NcNotification(
|
||||
notificationId: notificationId,
|
||||
app: app,
|
||||
user: user,
|
||||
subject: subject,
|
||||
message: message,
|
||||
datetime: datetime ?? '2026-04-11T12:00:00+00:00',
|
||||
objectType: objectType,
|
||||
objectId: objectId,
|
||||
icon: icon,
|
||||
link: link,
|
||||
);
|
||||
|
||||
ListItem makeListItem({
|
||||
int id = 1,
|
||||
int listId = 1,
|
||||
|
||||
116
test/models/notification_test.dart
Normal file
@@ -0,0 +1,116 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pantry/models/notification.dart';
|
||||
|
||||
void main() {
|
||||
group('NcNotification.fromJson', () {
|
||||
test('parses a minimal JSON payload', () {
|
||||
final n = NcNotification.fromJson({
|
||||
'notification_id': 42,
|
||||
'app': 'pantry',
|
||||
'user': 'alice',
|
||||
'subject': 'alice added an item',
|
||||
'datetime': '2026-04-11T12:00:00+00:00',
|
||||
'object_type': 'item',
|
||||
'object_id': '5',
|
||||
});
|
||||
|
||||
expect(n.notificationId, 42);
|
||||
expect(n.app, 'pantry');
|
||||
expect(n.user, 'alice');
|
||||
expect(n.subject, 'alice added an item');
|
||||
expect(n.message, '');
|
||||
expect(n.datetime, '2026-04-11T12:00:00+00:00');
|
||||
expect(n.objectType, 'item');
|
||||
expect(n.objectId, '5');
|
||||
expect(n.icon, null);
|
||||
expect(n.link, null);
|
||||
});
|
||||
|
||||
test('prefers parsed subject/message over rich templates', () {
|
||||
final n = NcNotification.fromJson({
|
||||
'notification_id': 1,
|
||||
'app': 'pantry',
|
||||
'user': 'u',
|
||||
'subject': 'Alice uploaded a photo in My Home',
|
||||
'subjectRich': '{user} uploaded a photo in {house}',
|
||||
'message': 'plain message',
|
||||
'messageRich': 'rich message template',
|
||||
'datetime': '2026-01-01T00:00:00+00:00',
|
||||
'object_type': 't',
|
||||
'object_id': '1',
|
||||
});
|
||||
|
||||
expect(n.subject, 'Alice uploaded a photo in My Home');
|
||||
expect(n.message, 'plain message');
|
||||
});
|
||||
|
||||
test('falls back to rich templates when parsed subject is missing', () {
|
||||
final n = NcNotification.fromJson({
|
||||
'notification_id': 1,
|
||||
'subjectRich': '{user} uploaded a photo',
|
||||
'messageRich': 'rich message',
|
||||
});
|
||||
|
||||
expect(n.subject, '{user} uploaded a photo');
|
||||
expect(n.message, 'rich message');
|
||||
});
|
||||
|
||||
test('falls back to empty strings when fields are missing', () {
|
||||
final n = NcNotification.fromJson({'notification_id': 1});
|
||||
|
||||
expect(n.app, '');
|
||||
expect(n.user, '');
|
||||
expect(n.subject, '');
|
||||
expect(n.message, '');
|
||||
expect(n.datetime, '');
|
||||
expect(n.objectType, '');
|
||||
expect(n.objectId, '');
|
||||
});
|
||||
|
||||
test('preserves optional icon and link', () {
|
||||
final n = NcNotification.fromJson({
|
||||
'notification_id': 1,
|
||||
'app': 'pantry',
|
||||
'user': 'u',
|
||||
'subject': 's',
|
||||
'datetime': '2026-01-01T00:00:00+00:00',
|
||||
'object_type': 't',
|
||||
'object_id': '1',
|
||||
'icon': 'https://example.com/icon.png',
|
||||
'link': 'https://example.com/link',
|
||||
});
|
||||
|
||||
expect(n.icon, 'https://example.com/icon.png');
|
||||
expect(n.link, 'https://example.com/link');
|
||||
});
|
||||
});
|
||||
|
||||
group('NcNotification.parsedDatetime', () {
|
||||
test('parses a valid ISO-8601 string', () {
|
||||
final n = NcNotification.fromJson({
|
||||
'notification_id': 1,
|
||||
'datetime': '2026-04-11T12:30:45+00:00',
|
||||
});
|
||||
|
||||
final dt = n.parsedDatetime;
|
||||
expect(dt, isNotNull);
|
||||
expect(dt!.year, 2026);
|
||||
expect(dt.month, 4);
|
||||
expect(dt.day, 11);
|
||||
});
|
||||
|
||||
test('returns null for unparseable strings', () {
|
||||
final n = NcNotification.fromJson({
|
||||
'notification_id': 1,
|
||||
'datetime': 'not a date',
|
||||
});
|
||||
|
||||
expect(n.parsedDatetime, null);
|
||||
});
|
||||
|
||||
test('returns null for empty datetime', () {
|
||||
final n = NcNotification.fromJson({'notification_id': 1});
|
||||
expect(n.parsedDatetime, null);
|
||||
});
|
||||
});
|
||||
}
|
||||
107
test/views/notifications_view_test.dart
Normal file
@@ -0,0 +1,107 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pantry/i18n.dart';
|
||||
import 'package:pantry/views/notifications/notifications_view.dart';
|
||||
|
||||
import '../helpers/fakes.dart';
|
||||
import '../helpers/test_models.dart';
|
||||
|
||||
void main() {
|
||||
group('NotificationsView', () {
|
||||
testWidgets('shows empty state when there are no notifications', (
|
||||
tester,
|
||||
) async {
|
||||
final controller = FakeNotificationsController();
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(home: NotificationsView(controller: controller)),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text(m.notifications.empty), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('renders a list of notifications', (tester) async {
|
||||
final controller = FakeNotificationsController(
|
||||
notifications: [
|
||||
makeNotification(notificationId: 1, subject: 'alice added Milk'),
|
||||
makeNotification(notificationId: 2, subject: 'bob uploaded a photo'),
|
||||
],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(home: NotificationsView(controller: controller)),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('alice added Milk'), findsOneWidget);
|
||||
expect(find.text('bob uploaded a photo'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows the dismiss-all action when notifications exist', (
|
||||
tester,
|
||||
) async {
|
||||
final controller = FakeNotificationsController(
|
||||
notifications: [makeNotification()],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(home: NotificationsView(controller: controller)),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byIcon(Icons.done_all), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('hides the dismiss-all action when empty', (tester) async {
|
||||
final controller = FakeNotificationsController();
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(home: NotificationsView(controller: controller)),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byIcon(Icons.done_all), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('tapping dismiss-all calls controller.dismissAll', (
|
||||
tester,
|
||||
) async {
|
||||
final controller = FakeNotificationsController(
|
||||
notifications: [makeNotification()],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(home: NotificationsView(controller: controller)),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byIcon(Icons.done_all));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(controller.dismissAllCalls, 1);
|
||||
});
|
||||
|
||||
testWidgets('swipe-to-dismiss removes a notification', (tester) async {
|
||||
final controller = FakeNotificationsController(
|
||||
notifications: [
|
||||
makeNotification(notificationId: 1, subject: 'first'),
|
||||
makeNotification(notificationId: 2, subject: 'second'),
|
||||
],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(home: NotificationsView(controller: controller)),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.drag(find.text('first'), const Offset(-500, 0));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(controller.dismissCalls, 1);
|
||||
expect(controller.lastDismissed?.notificationId, 1);
|
||||
expect(find.text('first'), findsNothing);
|
||||
expect(find.text('second'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
87
test/widgets/notifications_bell_test.dart
Normal file
@@ -0,0 +1,87 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pantry/widgets/notifications_bell.dart';
|
||||
|
||||
import '../helpers/fakes.dart';
|
||||
import '../helpers/test_app.dart';
|
||||
import '../helpers/test_models.dart';
|
||||
|
||||
void main() {
|
||||
group('NotificationsBell', () {
|
||||
testWidgets('renders the bell icon', (tester) async {
|
||||
final controller = FakeNotificationsController();
|
||||
var tapped = 0;
|
||||
|
||||
await tester.pumpWidget(
|
||||
wrapForTest(
|
||||
NotificationsBell(controller: controller, onTap: () => tapped++),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.byIcon(Icons.notifications_outlined), findsOneWidget);
|
||||
expect(tapped, 0);
|
||||
});
|
||||
|
||||
testWidgets('shows no badge when there are no notifications', (
|
||||
tester,
|
||||
) async {
|
||||
final controller = FakeNotificationsController();
|
||||
|
||||
await tester.pumpWidget(
|
||||
wrapForTest(NotificationsBell(controller: controller, onTap: () {})),
|
||||
);
|
||||
|
||||
expect(find.text('0'), findsNothing);
|
||||
expect(find.text('1'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('shows badge with count when there are notifications', (
|
||||
tester,
|
||||
) async {
|
||||
final controller = FakeNotificationsController(
|
||||
notifications: [
|
||||
makeNotification(notificationId: 1),
|
||||
makeNotification(notificationId: 2),
|
||||
makeNotification(notificationId: 3),
|
||||
],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
wrapForTest(NotificationsBell(controller: controller, onTap: () {})),
|
||||
);
|
||||
|
||||
expect(find.text('3'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows "99+" when count exceeds 99', (tester) async {
|
||||
final controller = FakeNotificationsController(
|
||||
notifications: List.generate(
|
||||
120,
|
||||
(i) => makeNotification(notificationId: i),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
wrapForTest(NotificationsBell(controller: controller, onTap: () {})),
|
||||
);
|
||||
|
||||
expect(find.text('99+'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('invokes onTap when pressed', (tester) async {
|
||||
final controller = FakeNotificationsController();
|
||||
var tapped = 0;
|
||||
|
||||
await tester.pumpWidget(
|
||||
wrapForTest(
|
||||
NotificationsBell(controller: controller, onTap: () => tapped++),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.notifications_outlined));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tapped, 1);
|
||||
});
|
||||
});
|
||||
}
|
||||