45 Commits

Author SHA1 Message Date
9c53cc52e9 Return messages_sent`from channel-preview
Some checks failed
Build Docker and Deploy / Build Docker Container (push) Successful in 1m22s
Build Docker and Deploy / Run Unit-Tests (push) Failing after 9m38s
Build Docker and Deploy / Deploy to Server (push) Has been skipped
2025-04-12 20:30:19 +02:00
e6709cd4af implement changing username 2025-04-12 14:35:08 +02:00
cdb92757aa Fix quota_used returning old value when there were no messages today 2025-04-12 14:04:22 +02:00
3c5da802a7 Add format+mono mode to debug request view 2025-04-12 14:03:56 +02:00
05e2fcf185 Upgrade dependencies, android sdk, flutter, gradle, etc 2025-04-12 12:12:49 +02:00
8ebd95a4b8 fix build 2024-10-23 15:22:08 +02:00
80d4e18a23 bugfixes 2024-10-20 03:34:34 +02:00
cc672d2f20 Channel QR Code scanner [WIP] 2024-10-19 22:39:05 +02:00
1cf14e65a9 Subscribe/unsubscribe from channels 2024-10-19 20:00:44 +02:00
9b2e429d3d Fix warnings and upgrade 2024-10-19 17:52:27 +02:00
2f73a21a41 Added raw-failure logs to flutter app (to debug init errors) 2024-10-19 17:16:34 +02:00
05eb37bc80 Fix flutter project with new android-studio version:
- upgrade gradle from 7.5 to 7.6.1
  - change java target from 1.8 to 17
  - set flutter jdk-dir to system-jdk (instead of now-incompatible android-studio bundled jdk)
    (android studio bundles jdk 21, jdk 21 needs gradle 8.4, flutetr does not work with gradle > 7.6.1 )
    => see https://docs.gradle.org/current/userguide/compatibility.html#java
    =>     https://www.liquidbcn.com/en/insights/solving-issues-android-studio-ladybug-java-21-flutter
2024-10-15 19:41:15 +02:00
779c86d8ac Fix error after migration (preprocessor no reinitialized) 2024-09-28 00:13:22 +02:00
d9a14c9973 Better migration handling 2024-09-20 23:50:34 +02:00
7546c2a1a4 Fix test pipeline
All checks were successful
Build Docker and Deploy / Build Docker Container (push) Successful in 1m17s
Build Docker and Deploy / Run Unit-Tests (push) Successful in 2m38s
Build Docker and Deploy / Deploy to Server (push) Successful in 9s
2024-09-20 21:23:26 +02:00
d21d775764 Add ListSenderNames api route and use params.Add(..) in Filter classes
Some checks failed
Build Docker and Deploy / Run Unit-Tests (push) Failing after 10s
Build Docker and Deploy / Build Docker Container (push) Successful in 1m14s
Build Docker and Deploy / Deploy to Server (push) Has been skipped
2024-09-20 20:37:55 +02:00
352f1ca0d1 Fully switch away from mattn sqlite to glebarez sqlite 2024-09-20 17:21:32 +02:00
584a9e983f Add tests [TestListSenderNames] [TestListUserSenderNames] 2024-09-20 16:33:45 +02:00
5dd94eca38 Add test stage to pipeline 2024-09-20 15:39:15 +02:00
d8c06e3de2 Fix test [TestListMessagesFilterChannel] 2024-09-20 15:36:16 +02:00
3adeadf6fb Work on implementing search filter in app [WIP] 2024-09-19 19:46:46 +02:00
9d35916280 Fix missing field in clients struct and non-partial fcmtoken index (also streamline db migrations)
All checks were successful
Build Docker and Deploy / Build Docker Container (push) Successful in 1m52s
Build Docker and Deploy / Deploy to Server (push) Successful in 7s
2024-09-17 22:26:45 +02:00
4c7632a144 Set delivery to FAILURE if [client|user|message|channel] no longer exists
All checks were successful
Build Docker and Deploy / Build Docker Container (push) Successful in 1m52s
Build Docker and Deploy / Deploy to Server (push) Successful in 6s
2024-09-17 20:49:10 +02:00
e329e13a02 Auto-delete clients when FB returns UNREGISTERED
All checks were successful
Build Docker and Deploy / Build Docker Container (push) Successful in 1m53s
Build Docker and Deploy / Deploy to Server (push) Successful in 7s
2024-09-16 20:11:28 +02:00
7ddaf5d9aa Migrate deliveries.next_delivery from type:string to type:int (SCNTime)
All checks were successful
Build Docker and Deploy / Build Docker Container (push) Successful in 1m46s
Build Docker and Deploy / Deploy to Server (push) Successful in 6s
2024-09-16 18:26:28 +02:00
5da4c3d3b9 Fix dbConverter error when unmarshalling (failed) deliveries
All checks were successful
Build Docker and Deploy / Build Docker Container (push) Successful in 1m48s
Build Docker and Deploy / Deploy to Server (push) Successful in 5s
2024-09-16 17:55:13 +02:00
fb1560a1f5 go generate
All checks were successful
Build Docker and Deploy / Build Docker Container (push) Successful in 2m19s
Build Docker and Deploy / Deploy to Server (push) Successful in 13s
2024-09-16 16:46:26 +02:00
61d62f736c Merge branch 'flutter_app' 2024-09-16 16:23:46 +02:00
77362f1651 Merge branch 'refactor_server' 2024-09-16 16:23:15 +02:00
e93d125431 Properly handle click actions on notifications 2024-07-13 01:05:32 +02:00
74a935f6f1 Fix scn-requests box not being open in _onBackgroundMessage 2024-07-13 00:17:22 +02:00
be7035978b channel_message_list 2024-07-13 00:11:13 +02:00
778451fa4c channel list fixes 2024-07-12 23:08:56 +02:00
89d1e0f641 edit displayName/descriptionName of channel 2024-06-26 14:54:34 +02:00
1f9b65652d get channel->lastMessage from cache before hot-loading 2024-06-25 20:54:03 +02:00
2b23404461 channel_view page 2024-06-25 20:49:40 +02:00
e2dbe8866d Channel List/view WIP 2024-06-25 12:00:34 +02:00
7dad61dbbb Fix re-layout in message_view after data is loaded 2024-06-23 13:31:10 +02:00
9542405512 fix linebreaks in message.title in channel_list_item 2024-06-18 17:36:41 +02:00
59d28d3c49 auto-refresh message-list on FB message receive 2024-06-17 23:23:35 +02:00
600f3365f6 Disabled didPopNext() refresh of message_list 2024-06-17 22:54:45 +02:00
5b8a1e86e0 Save user+client in Prefs and only background-fetch them on startup 2024-06-17 22:53:03 +02:00
c8bc7665f7 Fix background messages in release-build 2024-06-17 22:26:48 +02:00
0bbe5fc7fa Working on message search+filter 2024-06-16 01:46:27 +02:00
e9ea573e33 Notifications (android via local) work 2024-06-15 21:29:51 +02:00
153 changed files with 6995 additions and 1658 deletions

View File

@@ -13,7 +13,7 @@ on:
jobs:
build_job:
build_server:
name: Build Docker Container
runs-on: bfb-cicd-latest
steps:
@@ -24,9 +24,49 @@ jobs:
- run: cd "${{ gitea.workspace }}/scnserver" && make docker
- run: cd "${{ gitea.workspace }}/scnserver" && make push-docker
deploy_job:
test_server:
name: Run Unit-Tests
runs-on: bfb-cicd-latest
steps:
- name: Check out code
uses: actions/checkout@v3
- name: Get Commiter Info
id: commiter_info
run: |
echo "NAME=$( git log -n 1 --pretty=format:%an )" >> $GITHUB_OUTPUT
echo "MAIL=$( git log -n 1 --pretty=format:%ae )" >> $GITHUB_OUTPUT
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: '${{ gitea.workspace }}/scnserver/go.mod'
cache: false
- name: Print Go Version
run: go version
- name: Run tests
run: cd "${{ gitea.workspace }}/scnserver" && make dgi && make swagger && SCN_TEST_LOGLEVEL=WARN make test
- name: Send failure mail
if: failure()
uses: dawidd6/action-send-mail@v3
with:
server_address: smtp.fastmail.com
server_port: 465
secure: true
username: ${{secrets.MAIL_USERNAME}}
password: ${{secrets.MAIL_PASSWORD}}
subject: Pipeline on '${{ gitea.repository }}' failed
to: ${{ steps.commiter_info.outputs.MAIL }}
from: Gitea Actions <gitea_actions@blackforestbytes.de>
body: "Go to https://gogs.blackforestbytes.com/${{ gitea.repository }}/actions"
deploy_server:
name: Deploy to Server
needs: [build_job]
needs: [build_server, test_server]
runs-on: ubuntu-latest
steps:
- name: Execute deploy on remote (via ssh)

6
android/.idea/AndroidProjectSystem.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
</component>
</project>

263
android/.idea/other.xml generated
View File

@@ -1,263 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="direct_access_persist.xml">
<option name="deviceSelectionList">
<list>
<PersistentDeviceSelectionData>
<option name="api" value="27" />
<option name="brand" value="DOCOMO" />
<option name="codename" value="F01L" />
<option name="id" value="F01L" />
<option name="manufacturer" value="FUJITSU" />
<option name="name" value="F-01L" />
<option name="screenDensity" value="360" />
<option name="screenX" value="720" />
<option name="screenY" value="1280" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="28" />
<option name="brand" value="DOCOMO" />
<option name="codename" value="SH-01L" />
<option name="id" value="SH-01L" />
<option name="manufacturer" value="SHARP" />
<option name="name" value="AQUOS sense2 SH-01L" />
<option name="screenDensity" value="480" />
<option name="screenX" value="1080" />
<option name="screenY" value="2160" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="31" />
<option name="brand" value="samsung" />
<option name="codename" value="a51" />
<option name="id" value="a51" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy A51" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />
<option name="codename" value="akita" />
<option name="id" value="akita" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 8a" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="samsung" />
<option name="codename" value="b0q" />
<option name="id" value="b0q" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy S22 Ultra" />
<option name="screenDensity" value="600" />
<option name="screenX" value="1440" />
<option name="screenY" value="3088" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="32" />
<option name="brand" value="google" />
<option name="codename" value="bluejay" />
<option name="id" value="bluejay" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 6a" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="29" />
<option name="brand" value="samsung" />
<option name="codename" value="crownqlteue" />
<option name="id" value="crownqlteue" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy Note9" />
<option name="screenDensity" value="420" />
<option name="screenX" value="2220" />
<option name="screenY" value="1080" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
<option name="codename" value="dm3q" />
<option name="id" value="dm3q" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy S23 Ultra" />
<option name="screenDensity" value="600" />
<option name="screenX" value="1440" />
<option name="screenY" value="3088" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="google" />
<option name="codename" value="felix" />
<option name="id" value="felix" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel Fold" />
<option name="screenDensity" value="420" />
<option name="screenX" value="2208" />
<option name="screenY" value="1840" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="google" />
<option name="codename" value="felix_camera" />
<option name="id" value="felix_camera" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel Fold (Camera-enabled)" />
<option name="screenDensity" value="420" />
<option name="screenX" value="2208" />
<option name="screenY" value="1840" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="samsung" />
<option name="codename" value="gts8uwifi" />
<option name="id" value="gts8uwifi" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy Tab S8 Ultra" />
<option name="screenDensity" value="320" />
<option name="screenX" value="1848" />
<option name="screenY" value="2960" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />
<option name="codename" value="husky" />
<option name="id" value="husky" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 8 Pro" />
<option name="screenDensity" value="390" />
<option name="screenX" value="1008" />
<option name="screenY" value="2244" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="30" />
<option name="brand" value="motorola" />
<option name="codename" value="java" />
<option name="id" value="java" />
<option name="manufacturer" value="Motorola" />
<option name="name" value="G20" />
<option name="screenDensity" value="280" />
<option name="screenX" value="720" />
<option name="screenY" value="1600" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="google" />
<option name="codename" value="lynx" />
<option name="id" value="lynx" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 7a" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="31" />
<option name="brand" value="google" />
<option name="codename" value="oriole" />
<option name="id" value="oriole" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 6" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="google" />
<option name="codename" value="panther" />
<option name="id" value="panther" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 7" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="31" />
<option name="brand" value="samsung" />
<option name="codename" value="q2q" />
<option name="id" value="q2q" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy Z Fold3" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1768" />
<option name="screenY" value="2208" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
<option name="codename" value="q5q" />
<option name="id" value="q5q" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy Z Fold5" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1812" />
<option name="screenY" value="2176" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="30" />
<option name="brand" value="google" />
<option name="codename" value="r11" />
<option name="id" value="r11" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel Watch" />
<option name="screenDensity" value="320" />
<option name="screenX" value="384" />
<option name="screenY" value="384" />
<option name="type" value="WEAR_OS" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="30" />
<option name="brand" value="google" />
<option name="codename" value="redfin" />
<option name="id" value="redfin" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 5" />
<option name="screenDensity" value="440" />
<option name="screenX" value="1080" />
<option name="screenY" value="2340" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />
<option name="codename" value="shiba" />
<option name="id" value="shiba" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 8" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="google" />
<option name="codename" value="tangorpro" />
<option name="id" value="tangorpro" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel Tablet" />
<option name="screenDensity" value="320" />
<option name="screenX" value="1600" />
<option name="screenY" value="2560" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="29" />
<option name="brand" value="samsung" />
<option name="codename" value="x1q" />
<option name="id" value="x1q" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy S20" />
<option name="screenDensity" value="480" />
<option name="screenX" value="1440" />
<option name="screenY" value="3200" />
</PersistentDeviceSelectionData>
</list>
</option>
</component>
</project>

17
android/.idea/runConfigurations.xml generated Normal file
View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RunConfigurationProducerService">
<option name="ignoredProducers">
<set>
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
</set>
</option>
</component>
</project>

View File

@@ -30,6 +30,7 @@ android {
targetCompatibility 1.8
sourceCompatibility 1.8
}
namespace 'com.blackforestbytes.simplecloudnotifier'
}
dependencies {

View File

@@ -1,7 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="com.blackforestbytes.simplecloudnotifier">
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />

View File

@@ -7,8 +7,8 @@ buildscript {
jcenter()
}
dependencies {
classpath 'com.android.tools.build:gradle:4.1.0'
classpath 'com.google.gms:google-services:4.3.4'
classpath 'com.android.tools.build:gradle:8.9.1'
classpath 'com.google.gms:google-services:4.3.10'
}
}

View File

@@ -14,3 +14,6 @@ org.gradle.jvmargs=-Xmx1536m
android.useAndroidX=true
android.enableJetifier=true
android.defaults.buildfeatures.buildconfig=true
android.nonTransitiveRClass=false
android.nonFinalResIds=false

View File

@@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-6.5-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip

2
flutter/.gitignore vendored
View File

@@ -5,6 +5,8 @@
firepit-log.txt
flutter_jank_*
_releases/*
#######################################################################################################################

View File

@@ -1,8 +1,39 @@
run:
# Setup
#
# flutter config --jdk-dir "/usr/lib/jvm/default-runtime/bin"
# sudo archlinux-java set java-17-openjdk
#
# runs app locally (linux)
run-linux:
dart run build_runner build
_JAVA_OPTIONS="" flutter run -d linux
# runs app locally (web | not really supported)
run-linux:
dart run build_runner build
_JAVA_OPTIONS="" flutter run -d web
# runs on android device (must have network adb enabled teh correct IP)
run-android:
ping -c1 10.10.10.177
adb connect 10.10.10.177:5555
flutter pub run build_runner build
flutter run
_JAVA_OPTIONS="" flutter run -d 10.10.10.177:5555
install-release:
# Install on Pixel 7a
flutter build apk --release
flutter run --release -d 35221JEHN07157
build-release:
flutter build apk --release
flutter build appbundle --release
flutter build linux --release
test:
dart analyze
@@ -13,9 +44,23 @@ fix:
gen:
flutter pub run build_runner build
# run `make run` in another terminal (or another variant of flutter run)
autoreload:
@# run `make run` in another terminal (or another variant of flutter run)
@
@_utils/autoreload.sh
icons:
flutter pub run flutter_launcher_icons -f "flutter_launcher_icons.yaml"
clean:
cd android && ./gradlew clean
flutter clean
# upgrade all packages (add --major-versions even updates across new major versions)
# https://docs.flutter.dev/release/upgrade
# upgrading flutter can be done via `flutter upgrade`: https://docs.flutter.dev/release/upgrade
# android/gradle updates should be done via androidStudio: https://docs.flutter.dev/release/breaking-changes/android-java-gradle-migration-guide
upgrade:
flutter upgrade
flutter pub upgrade
flutter doctor

View File

@@ -25,6 +25,15 @@
- [ ] Still @ERROR on scn-init, but no logs? - better persist error (write in SharedPrefs at error_$date=txt ?), also perhaps print first error line in scn-init notification?
- [ ] fix time format (in message-list, in card, top right) - midnight is shown as "24:05" instead of "00:05" - thats weird
- [ ] Add scrollbar
-> https://api.flutter.dev/flutter/material/Scrollbar-class.html
- [ ] you cant unsubscribe from foreign channel without completely loosing subscription.
perhaps subscriptions should have two cofirmed bool (both must be true to receive messages): confirmed-owner && confirmed-subscriber
Then the subscriber can unconfirm his half - without loosing the owner confirmation
-----
# TODO iOS specific

View File

@@ -11,3 +11,7 @@ GeneratedPluginRegistrant.java
key.properties
**/*.keystore
**/*.jks
build/
app/.cxx/

View File

@@ -34,15 +34,16 @@ if (keystorePropertiesFile.exists()) {
android {
namespace "com.blackforestbytes.simplecloudnotifier"
compileSdkVersion flutter.compileSdkVersion
ndkVersion flutter.ndkVersion
ndkVersion "27.0.12077973" // should be `flutter.ndkVersion` - but some plugins need 27, even though flutter still has the default value of 26 (flutter 3.29 | 2025-04-12)
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
coreLibraryDesugaringEnabled true
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '1.8'
jvmTarget = '17'
}
sourceSets {
@@ -55,6 +56,7 @@ android {
targetSdkVersion flutter.targetSdkVersion
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
multiDexEnabled true
}
signingConfigs {
@@ -77,4 +79,9 @@ flutter {
source '../..'
}
dependencies {}
dependencies {
implementation 'androidx.window:window:1.0.0'
implementation 'androidx.window:window-java:1.0.0'
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.2.2'
}

27
flutter/android/app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,27 @@
## Gson rules
# Gson uses generic type information stored in a class file when working with fields. Proguard
# removes such information by default, so configure it to keep all of it.
-keepattributes Signature
# For using GSON @Expose annotation
-keepattributes *Annotation*
# Gson specific classes
-dontwarn sun.misc.**
#-keep class com.google.gson.stream.** { *; }
# Prevent proguard from stripping interface information from TypeAdapter, TypeAdapterFactory,
# JsonSerializer, JsonDeserializer instances (so they can be used in @JsonAdapter)
-keep class * extends com.google.gson.TypeAdapter
-keep class * implements com.google.gson.TypeAdapterFactory
-keep class * implements com.google.gson.JsonSerializer
-keep class * implements com.google.gson.JsonDeserializer
# Prevent R8 from leaving Data object members always null
-keepclassmembers,allowobfuscation class * {
@com.google.gson.annotations.SerializedName <fields>;
}
# Retain generic signatures of TypeToken and its subclasses with R8 version 3.0 and higher.
-keep,allowobfuscation,allowshrinking class com.google.gson.reflect.TypeToken
-keep,allowobfuscation,allowshrinking class * extends com.google.gson.reflect.TypeToken

View File

@@ -31,6 +31,9 @@
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ActionBroadcastReceiver" />
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data

Binary file not shown.

After

Width:  |  Height:  |  Size: 472 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 B

View File

@@ -0,0 +1,34 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillType="evenOdd"
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
android:strokeColor="#00000000"
android:strokeWidth="1">
<aapt:attr name="android:fillColor">
<gradient
android:endX="78.5885"
android:endY="90.9159"
android:startX="48.7653"
android:startY="61.0927"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
android:strokeColor="#00000000"
android:strokeWidth="1" />
</vector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 551 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 949 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools" tools:keep="@drawable/*" />

View File

@@ -1,15 +1,3 @@
buildscript {
ext.kotlin_version = '1.7.10'
repositories {
google()
mavenCentral()
}
dependencies {
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}
allprojects {
repositories {
google()

View File

@@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip

View File

@@ -23,7 +23,8 @@ pluginManagement {
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "7.3.0" apply false
id "com.android.application" version "8.9.1" apply false
id "org.jetbrains.kotlin.android" version "2.1.10" apply false
// START: FlutterFire Configuration
id "com.google.gms.google-services" version "4.3.15" apply false
// END: FlutterFire Configuration

View File

@@ -1,13 +1,27 @@
import UIKit
import Flutter
import flutter_local_notifications
@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
// This is required to make any communication available in the action isolate.
FlutterLocalNotificationsPlugin.setPluginRegistrantCallback { (registry) in
GeneratedPluginRegistrant.register(with: registry)
}
if #available(iOS 10.0, *) {
UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate
}
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}

View File

@@ -45,5 +45,10 @@
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>This app needs camera access to scan QR codes</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>This app needs photos access to get QR code from photo library</string>
</dict>
</plist>

View File

@@ -5,13 +5,15 @@ import 'package:simplecloudnotifier/api/api_exception.dart';
import 'package:simplecloudnotifier/models/api_error.dart';
import 'package:simplecloudnotifier/models/client.dart';
import 'package:simplecloudnotifier/models/keytoken.dart';
import 'package:simplecloudnotifier/models/sender_name_statistics.dart';
import 'package:simplecloudnotifier/models/subscription.dart';
import 'package:simplecloudnotifier/models/user.dart';
import 'package:simplecloudnotifier/state/app_auth.dart';
import 'package:simplecloudnotifier/state/application_log.dart';
import 'package:simplecloudnotifier/state/globals.dart';
import 'package:simplecloudnotifier/state/request_log.dart';
import 'package:simplecloudnotifier/models/channel.dart';
import 'package:simplecloudnotifier/models/message.dart';
import 'package:simplecloudnotifier/models/scn_message.dart';
import 'package:simplecloudnotifier/state/token_source.dart';
import 'package:simplecloudnotifier/utils/toaster.dart';
@@ -26,6 +28,27 @@ enum ChannelSelector {
final String apiKey;
}
class MessageFilter {
List<String>? channelIDs;
List<String>? searchFilter;
List<String>? senderNames;
List<String>? usedKeys;
List<int>? priority;
DateTime? timeBefore;
DateTime? timeAfter;
bool? hasSenderName;
MessageFilter({
this.channelIDs,
this.searchFilter,
this.senderNames,
this.usedKeys,
this.priority,
this.timeBefore,
this.timeAfter,
});
}
class APIClient {
static const String _base = 'https://simplecloudnotifier.de/api/v2';
@@ -33,7 +56,7 @@ class APIClient {
required String name,
required String method,
required String relURL,
Map<String, String>? query,
Map<String, Iterable<String>>? query,
required T Function(Map<String, dynamic> json)? fn,
dynamic jsonBody,
String? authToken,
@@ -45,7 +68,7 @@ class APIClient {
final req = http.Request(method, uri);
print('[REQUEST|RUN] [${method}] ${name}');
print('[REQUEST|RUN] [${method}] ${name} | ${uri.toString()}');
if (jsonBody != null) {
req.body = jsonEncode(jsonBody);
@@ -79,21 +102,23 @@ class APIClient {
}
if (responseStatusCode != 200) {
try {
final apierr = APIError.fromJson(jsonDecode(responseBody) as Map<String, dynamic>);
APIError apierr;
RequestLog.addRequestAPIError(name, t0, method, uri, req.body, req.headers, responseStatusCode, responseBody, responseHeaders, apierr);
Toaster.error("Error", 'Request "${name}" failed');
throw APIException(responseStatusCode, apierr.error, apierr.errhighlight, apierr.message);
try {
apierr = APIError.fromJson(jsonDecode(responseBody) as Map<String, dynamic>);
} catch (exc, trace) {
ApplicationLog.warn('Failed to decode api response as error-object', additional: exc.toString() + "\nBody:\n" + responseBody, trace: trace);
}
RequestLog.addRequestErrorStatuscode(name, t0, method, uri, req.body, req.headers, responseStatusCode, responseBody, responseHeaders);
Toaster.error("Error", 'Request "${name}" failed');
throw Exception('API request failed with status code ${responseStatusCode}');
}
RequestLog.addRequestAPIError(name, t0, method, uri, req.body, req.headers, responseStatusCode, responseBody, responseHeaders, apierr);
Toaster.error("Error", apierr.message);
throw APIException(responseStatusCode, apierr.error, apierr.errhighlight, apierr.message);
}
try {
final data = jsonDecode(responseBody);
@@ -137,6 +162,20 @@ class APIClient {
);
}
static Future<User> updateUser(TokenSource auth, String uid, {String? username, String? proToken}) async {
return await _request(
name: 'updateUser',
method: 'PATCH',
relURL: 'users/$uid',
jsonBody: {
if (username != null) 'username': username,
if (proToken != null) 'pro_token': proToken,
},
fn: User.fromJson,
authToken: auth.getToken(),
);
}
static Future<Client> addClient(TokenSource auth, String fcmToken, String agentModel, String agentVersion, String? name, String clientType) async {
return await _request(
name: 'addClient',
@@ -185,7 +224,9 @@ class APIClient {
name: 'getChannelList',
method: 'GET',
relURL: 'users/${auth.getUserID()}/channels',
query: {'selector': sel.apiKey},
query: {
'selector': [sel.apiKey]
},
fn: (json) => ChannelWithSubscription.fromJsonArray(json['channels'] as List<dynamic>),
authToken: auth.getToken(),
);
@@ -211,28 +252,62 @@ class APIClient {
);
}
static Future<(String, List<Message>)> getMessageList(TokenSource auth, String pageToken, {int? pageSize, List<String>? channelIDs}) async {
static Future<ChannelWithSubscription> updateChannel(AppAuth auth, String cid, {String? displayName, String? descriptionName}) async {
return await _request(
name: 'updateChannel',
method: 'PATCH',
relURL: 'users/${auth.getUserID()}/channels/${cid}',
jsonBody: {
if (displayName != null) 'display_name': displayName,
if (descriptionName != null) 'description_name': descriptionName,
},
fn: ChannelWithSubscription.fromJson,
authToken: auth.getToken(),
);
}
static Future<(String, List<SCNMessage>)> getMessageList(TokenSource auth, String pageToken, {int? pageSize, MessageFilter? filter}) async {
return await _request(
name: 'getMessageList',
method: 'GET',
relURL: 'messages',
query: {
'next_page_token': pageToken,
if (pageSize != null) 'page_size': pageSize.toString(),
if (channelIDs != null) 'channel_id': channelIDs.join(","),
'next_page_token': [pageToken],
if (pageSize != null) 'page_size': [pageSize.toString()],
if (filter?.searchFilter != null) 'search': filter!.searchFilter!,
if (filter?.channelIDs != null) 'channel_id': filter!.channelIDs!,
if (filter?.senderNames != null) 'sender': filter!.senderNames!,
if (filter?.hasSenderName != null) 'has_sender': [filter!.hasSenderName!.toString()],
if (filter?.timeBefore != null) 'before': [filter!.timeBefore!.toIso8601String()],
if (filter?.timeAfter != null) 'after': [filter!.timeAfter!.toIso8601String()],
if (filter?.priority != null) 'priority': filter!.priority!.map((p) => p.toString()).toList(),
if (filter?.usedKeys != null) 'used_key': filter!.usedKeys!,
},
fn: (json) => Message.fromPaginatedJsonArray(json, 'messages', 'next_page_token'),
fn: (json) => SCNMessage.fromPaginatedJsonArray(json, 'messages', 'next_page_token'),
authToken: auth.getToken(),
);
}
static Future<Message> getMessage(TokenSource auth, String msgid) async {
static Future<SCNMessage> getMessage(TokenSource auth, String msgid) async {
return await _request(
name: 'getMessage',
method: 'GET',
relURL: 'messages/$msgid',
query: {},
fn: Message.fromJson,
fn: SCNMessage.fromJson,
authToken: auth.getToken(),
);
}
static Future<(String, List<SCNMessage>)> getChannelMessageList(TokenSource auth, String cid, String pageToken, {int? pageSize}) async {
return await _request(
name: 'getChannelMessageList',
method: 'GET',
relURL: 'users/${auth.getUserID()}/channels/${cid}/messages',
query: {
'next_page_token': [pageToken],
if (pageSize != null) 'page_size': [pageSize.toString()],
},
fn: (json) => SCNMessage.fromPaginatedJsonArray(json, 'messages', 'next_page_token'),
authToken: auth.getToken(),
);
}
@@ -247,6 +322,16 @@ class APIClient {
);
}
static Future<List<Subscription>> getChannelSubscriptions(TokenSource auth, String cid) async {
return await _request(
name: 'getChannelSubscriptions',
method: 'GET',
relURL: 'users/${auth.getUserID()}/channels/${cid}/subscriptions',
fn: (json) => Subscription.fromJsonArray(json['subscriptions'] as List<dynamic>),
authToken: auth.getToken(),
);
}
static Future<List<Client>> getClientList(TokenSource auth) async {
return await _request(
name: 'getClientList',
@@ -314,4 +399,63 @@ class APIClient {
authToken: token,
);
}
static Future<List<SenderNameStatistics>> getSenderNameList(TokenSource auth) async {
return await _request(
name: 'getSenderNameList',
method: 'GET',
relURL: 'users/${auth.getUserID()}/sender-names',
fn: (json) => SenderNameStatistics.fromJsonArray(json['sender_names'] as List<dynamic>),
authToken: auth.getToken(),
);
}
static Future<Subscription> subscribeToChannelbyID(TokenSource auth, String channelID) async {
return await _request(
name: 'subscribeToChannelbyID',
method: 'POST',
relURL: 'users/${auth.getUserID()}/subscriptions',
jsonBody: {
'channel_id': channelID,
},
fn: Subscription.fromJson,
authToken: auth.getToken(),
);
}
static Future<Subscription> deleteSubscription(TokenSource auth, String channelID, String subID) async {
return await _request(
name: 'deleteSubscription',
method: 'DELETE',
relURL: 'users/${auth.getUserID()}/subscriptions/${subID}',
fn: Subscription.fromJson,
authToken: auth.getToken(),
);
}
static Future<Subscription> confirmSubscription(TokenSource auth, String channelID, String subID) async {
return await _request(
name: 'confirmSubscription',
method: 'PATCH',
relURL: 'users/${auth.getUserID()}/subscriptions/${subID}',
jsonBody: {
'confirmed': true,
},
fn: Subscription.fromJson,
authToken: auth.getToken(),
);
}
static Future<Subscription> unconfirmSubscription(TokenSource auth, String channelID, String subID) async {
return await _request(
name: 'unconfirmSubscription',
method: 'PATCH',
relURL: 'users/${auth.getUserID()}/subscriptions/${subID}',
jsonBody: {
'confirmed': false,
},
fn: Subscription.fromJson,
authToken: auth.getToken(),
);
}
}

View File

@@ -1,7 +1,7 @@
class APIException implements Exception {
final int httpStatus;
final int error;
final String errHighlight;
final int errHighlight;
final String message;
APIException(this.httpStatus, this.error, this.errHighlight, this.message);

View File

@@ -1,81 +0,0 @@
import 'package:flutter/material.dart';
// https://stackoverflow.com/questions/46480221/flutter-floating-action-button-with-speed-dail
class FabWithIcons extends StatefulWidget {
FabWithIcons({super.key, required this.icons, required this.onIconTapped});
final List<IconData> icons;
final ValueChanged<int> onIconTapped;
@override
State createState() => FabWithIconsState();
}
class FabWithIconsState extends State<FabWithIcons> with TickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 250),
);
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: List.generate(widget.icons.length, (int index) {
return _buildChild(index);
}).toList()
..add(
_buildFab(),
),
);
}
Widget _buildChild(int index) {
Color backgroundColor = Theme.of(context).cardColor;
Color foregroundColor = Theme.of(context).secondaryHeaderColor;
return Container(
height: 70.0,
width: 56.0,
alignment: FractionalOffset.topCenter,
child: ScaleTransition(
scale: CurvedAnimation(
parent: _controller,
curve: Interval(0.0, 1.0 - index / widget.icons.length / 2.0, curve: Curves.easeOut),
),
child: FloatingActionButton(
backgroundColor: backgroundColor,
mini: true,
child: Icon(widget.icons[index], color: foregroundColor),
onPressed: () => _onTapped(index),
),
),
);
}
Widget _buildFab() {
return FloatingActionButton(
onPressed: () {
if (_controller.isDismissed) {
_controller.forward();
} else {
_controller.reverse();
}
},
tooltip: 'Increment',
elevation: 2.0,
child: const Icon(Icons.add),
);
}
void _onTapped(int index) {
_controller.reverse();
widget.onIconTapped(index);
}
}

View File

@@ -3,17 +3,20 @@ import 'package:flutter/material.dart';
class HidableFAB extends StatelessWidget {
final VoidCallback? onPressed;
final IconData icon;
final Object heroTag;
const HidableFAB({
super.key,
this.onPressed,
required this.icon,
required this.heroTag,
});
Widget build(BuildContext context) {
return Visibility(
visible: MediaQuery.viewInsetsOf(context).bottom == 0.0, // hide when keyboard is shown
child: FloatingActionButton(
heroTag: this.heroTag,
onPressed: onPressed,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(17))),
elevation: 2.0,

View File

@@ -1,18 +1,21 @@
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:provider/provider.dart';
import 'package:simplecloudnotifier/components/layout/app_bar_filter_dialog.dart';
import 'package:simplecloudnotifier/components/layout/app_bar_progress_indicator.dart';
import 'package:simplecloudnotifier/pages/debug/debug_main.dart';
import 'package:simplecloudnotifier/pages/message_list/message_filter_chiplet.dart';
import 'package:simplecloudnotifier/settings/app_settings.dart';
import 'package:simplecloudnotifier/state/app_bar_state.dart';
import 'package:simplecloudnotifier/state/app_events.dart';
import 'package:simplecloudnotifier/state/app_theme.dart';
import 'package:simplecloudnotifier/utils/navi.dart';
class SCNAppBar extends StatelessWidget implements PreferredSizeWidget {
const SCNAppBar({
class SCNAppBar extends StatefulWidget implements PreferredSizeWidget {
SCNAppBar({
Key? key,
required this.title,
required this.showThemeSwitch,
required this.showDebug,
required this.showSearch,
required this.showShare,
this.onShare = null,
@@ -20,16 +23,33 @@ class SCNAppBar extends StatelessWidget implements PreferredSizeWidget {
final String? title;
final bool showThemeSwitch;
final bool showDebug;
final bool showSearch;
final bool showShare;
final void Function()? onShare;
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
@override
State<SCNAppBar> createState() => _SCNAppBarState();
}
class _SCNAppBarState extends State<SCNAppBar> {
final TextEditingController _ctrlSearchField = TextEditingController();
@override
void dispose() {
_ctrlSearchField.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final cfg = Provider.of<AppSettings>(context);
var actions = <Widget>[];
if (showDebug) {
if (cfg.showDebugButton) {
actions.add(IconButton(
icon: const Icon(FontAwesomeIcons.solidSpiderBlackWidow),
tooltip: 'Debug',
@@ -39,7 +59,7 @@ class SCNAppBar extends StatelessWidget implements PreferredSizeWidget {
));
}
if (showThemeSwitch) {
if (widget.showThemeSwitch) {
actions.add(Consumer<AppTheme>(
builder: (context, appTheme, child) => IconButton(
icon: Icon(appTheme.darkMode ? FontAwesomeIcons.solidSun : FontAwesomeIcons.solidMoon),
@@ -48,54 +68,118 @@ class SCNAppBar extends StatelessWidget implements PreferredSizeWidget {
),
));
} else {
actions.add(Visibility(
visible: false,
maintainSize: true,
maintainAnimation: true,
maintainState: true,
child: IconButton(
icon: const Icon(FontAwesomeIcons.square),
onPressed: () {/*TODO*/},
),
));
actions.add(_buildSpacer());
}
if (showSearch) {
if (widget.showSearch) {
actions.add(IconButton(
icon: const Icon(FontAwesomeIcons.solidFilter),
tooltip: 'Filter',
onPressed: () => _showFilterDialog(context),
));
actions.add(IconButton(
icon: const Icon(FontAwesomeIcons.solidMagnifyingGlass),
tooltip: 'Search',
onPressed: () {/*TODO*/},
onPressed: () => AppBarState().setShowSearchField(true),
));
} else if (showShare) {
} else if (widget.showShare) {
actions.add(_buildSpacer());
actions.add(IconButton(
icon: const Icon(FontAwesomeIcons.solidShareNodes),
tooltip: 'Share',
onPressed: onShare ?? () {},
onPressed: widget.onShare ?? () {},
));
} else {
actions.add(Visibility(
visible: false,
maintainSize: true,
maintainAnimation: true,
maintainState: true,
child: IconButton(
icon: const Icon(FontAwesomeIcons.square),
onPressed: () {/*TODO*/},
),
));
actions.add(_buildSpacer());
actions.add(_buildSpacer());
}
return Consumer<AppBarState>(builder: (context, value, child) {
if (value.showSearchField) {
return AppBar(
title: Text(title ?? 'Simple Cloud Notifier 2.0'),
leading: IconButton(
icon: const Icon(FontAwesomeIcons.solidArrowLeft),
onPressed: () {
value.setShowSearchField(false);
},
),
title: _buildSearchTextField(context),
actions: [
IconButton(
icon: const Icon(FontAwesomeIcons.solidMagnifyingGlass),
onPressed: () {
value.setShowSearchField(false);
final chiplet = MessageFilterChiplet(label: _ctrlSearchField.text, value: _ctrlSearchField.text, type: MessageFilterChipletType.search);
AppEvents().notifyFilterListeners([MessageFilterChipletType.search], [chiplet]);
_ctrlSearchField.clear();
},
),
],
backgroundColor: Theme.of(context).secondaryHeaderColor,
bottom: PreferredSize(
preferredSize: Size(double.infinity, 1.0),
child: AppBarProgressIndicator(show: value.loadingIndeterminate),
),
);
} else {
return AppBar(
title: Text(widget.title ?? 'SCN'),
actions: actions,
backgroundColor: Theme.of(context).secondaryHeaderColor,
bottom: PreferredSize(
preferredSize: Size(double.infinity, 1.0),
child: AppBarProgressIndicator(),
child: AppBarProgressIndicator(show: value.loadingIndeterminate),
),
);
}
});
}
Visibility _buildSpacer() {
return Visibility(
visible: false,
maintainSize: true,
maintainAnimation: true,
maintainState: true,
child: IconButton(
icon: const Icon(FontAwesomeIcons.square),
onPressed: () {/* NO-OP */},
),
);
}
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
Widget _buildSearchTextField(BuildContext context) {
return TextField(
controller: _ctrlSearchField,
autofocus: true,
style: TextStyle(fontSize: 20),
textInputAction: TextInputAction.search,
decoration: InputDecoration(
hintText: 'Search',
),
onSubmitted: (value) {
AppBarState().setShowSearchField(false);
final chiplet = MessageFilterChiplet(label: _ctrlSearchField.text, value: _ctrlSearchField.text, type: MessageFilterChipletType.search);
AppEvents().notifyFilterListeners([MessageFilterChipletType.search], [chiplet]);
_ctrlSearchField.clear();
},
);
}
void _showFilterDialog(BuildContext context) {
showDialog<void>(
context: context,
barrierDismissible: true,
barrierColor: Colors.transparent,
builder: (BuildContext context) {
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(0)),
alignment: Alignment.topCenter,
insetPadding: EdgeInsets.fromLTRB(0, this.widget.preferredSize.height, 0, 0),
backgroundColor: Colors.transparent,
child: AppBarFilterDialog(),
);
},
);
}
}

View File

@@ -0,0 +1,122 @@
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:simplecloudnotifier/components/modals/filter_modal_channel.dart';
import 'package:simplecloudnotifier/components/modals/filter_modal_keytoken.dart';
import 'package:simplecloudnotifier/components/modals/filter_modal_priority.dart';
import 'package:simplecloudnotifier/components/modals/filter_modal_sendername.dart';
import 'package:simplecloudnotifier/components/modals/filter_modal_time.dart';
import 'package:simplecloudnotifier/state/app_bar_state.dart';
import 'package:simplecloudnotifier/utils/navi.dart';
class AppBarFilterDialog extends StatefulWidget {
@override
_AppBarFilterDialogState createState() => _AppBarFilterDialogState();
}
class _AppBarFilterDialogState extends State<AppBarFilterDialog> {
double _height = 0;
double _targetHeight = 4 + (48 * 6) + (16 * 5) + 4;
@override
void initState() {
super.initState();
Future.delayed(Duration.zero, () {
setState(() {
_height = _targetHeight;
});
});
}
@override
Widget build(BuildContext context) {
double vpWidth = MediaQuery.sizeOf(context).width;
return Container(
margin: const EdgeInsets.all(0),
width: vpWidth,
color: Colors.transparent,
child: Column(
children: [
Container(
color: Theme.of(context).secondaryHeaderColor,
child: AnimatedContainer(
duration: Duration(milliseconds: 350),
curve: Curves.easeInCubic,
height: _height,
child: ClipRect(
child: OverflowBox(
alignment: Alignment.topCenter,
maxWidth: vpWidth,
minWidth: vpWidth,
minHeight: 0,
maxHeight: _targetHeight,
child: Column(
children: [
SizedBox(height: 4),
_buildFilterItem(context, FontAwesomeIcons.magnifyingGlass, 'Search', _showSearch),
Divider(),
_buildFilterItem(context, FontAwesomeIcons.snake, 'Channel', _showChannelModal),
Divider(),
_buildFilterItem(context, FontAwesomeIcons.signature, 'Sender', _showSenderModal),
Divider(),
_buildFilterItem(context, FontAwesomeIcons.timer, 'Time', _showTimeModal),
Divider(),
_buildFilterItem(context, FontAwesomeIcons.bolt, 'Priority', _showPriorityModal),
Divider(),
_buildFilterItem(context, FontAwesomeIcons.gearCode, 'Key', _showKeytokenModal),
Divider(),
_buildFilterItem(context, FontAwesomeIcons.magnifyingGlassPlus, 'Search (Plain)', _showPlainSearchModal),
SizedBox(height: 4),
],
),
),
),
),
),
Expanded(child: GestureDetector(child: Container(width: vpWidth, color: Color(0x88000000)), onTap: () => Navi.popDialog(context))),
],
),
);
}
Widget _buildFilterItem(BuildContext context, IconData icon, String label, void Function(BuildContext context) action) {
return ListTile(
visualDensity: VisualDensity.compact,
title: Text(label),
leading: Icon(icon),
onTap: () {
Navi.popDialog(context);
action(context);
},
);
}
void _showSearch(BuildContext context) {
AppBarState().setShowSearchField(true);
}
void _showPriorityModal(BuildContext context) {
showDialog<void>(context: context, builder: (BuildContext context) => FilterModalPriority());
}
void _showChannelModal(BuildContext context) {
showDialog<void>(context: context, builder: (BuildContext context) => FilterModalChannel());
}
void _showSenderModal(BuildContext context) {
showDialog<void>(context: context, builder: (BuildContext context) => FilterModalSendername());
}
void _showKeytokenModal(BuildContext context) {
showDialog<void>(context: context, builder: (BuildContext context) => FilterModalKeytoken());
}
void _showTimeModal(BuildContext context) {
showDialog<void>(context: context, builder: (BuildContext context) => FilterModalTime());
}
void _showPlainSearchModal(BuildContext context) {
//TODO showDialog<void>(context: context, builder: (BuildContext context) => FilterModalSearchPlain());
}
}

View File

@@ -1,21 +1,19 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:simplecloudnotifier/state/app_bar_state.dart';
class AppBarProgressIndicator extends StatelessWidget implements PreferredSizeWidget {
AppBarProgressIndicator({required this.show});
final bool show;
@override
Size get preferredSize => Size(double.infinity, 1.0);
@override
Widget build(BuildContext context) {
return Consumer<AppBarState>(
builder: (context, value, child) {
if (value.loadingIndeterminate) {
if (show) {
return LinearProgressIndicator(value: null);
} else {
return SizedBox.square(dimension: 4); // 4 height is the same as the LinearProgressIndicator
}
},
);
}
}

View File

@@ -7,7 +7,6 @@ class SCNScaffold extends StatelessWidget {
required this.child,
this.title,
this.showThemeSwitch = true,
this.showDebug = true,
this.showSearch = true,
this.showShare = false,
this.onShare = null,
@@ -16,7 +15,6 @@ class SCNScaffold extends StatelessWidget {
final Widget child;
final String? title;
final bool showThemeSwitch;
final bool showDebug;
final bool showSearch;
final bool showShare;
final void Function()? onShare;
@@ -27,7 +25,6 @@ class SCNScaffold extends StatelessWidget {
appBar: SCNAppBar(
title: title,
showThemeSwitch: showThemeSwitch,
showDebug: showDebug,
showSearch: showSearch,
showShare: showShare,
onShare: onShare ?? () {},

View File

@@ -0,0 +1,114 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:simplecloudnotifier/api/api_client.dart';
import 'package:simplecloudnotifier/models/channel.dart';
import 'package:simplecloudnotifier/pages/message_list/message_filter_chiplet.dart';
import 'package:simplecloudnotifier/state/app_auth.dart';
import 'package:simplecloudnotifier/state/app_events.dart';
import 'package:simplecloudnotifier/types/immediate_future.dart';
class FilterModalChannel extends StatefulWidget {
@override
_FilterModalChannelState createState() => _FilterModalChannelState();
}
class _FilterModalChannelState extends State<FilterModalChannel> {
Set<String> _selectedEntries = {};
late ImmediateFuture<List<Channel>>? _futureChannels;
@override
void initState() {
super.initState();
_futureChannels = null;
_futureChannels = ImmediateFuture.ofFuture(() async {
final userAcc = Provider.of<AppAuth>(context, listen: false);
if (!userAcc.isAuth()) throw new Exception('not logged in');
final channels = await APIClient.getChannelList(userAcc, ChannelSelector.all);
return channels.where((p) => p.subscription?.confirmed ?? false).map((e) => e.channel).toList(); // return only subscribed channels
}());
}
void toggleEntry(String channelID) {
setState(() {
if (_selectedEntries.contains(channelID)) {
_selectedEntries.remove(channelID);
} else {
_selectedEntries.add(channelID);
}
});
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Channels'),
content: Container(
width: 9000,
height: 9000,
child: () {
if (_futureChannels == null) {
return Center(child: CircularProgressIndicator());
}
return FutureBuilder(
future: _futureChannels!.future,
builder: ((context, snapshot) {
if (_futureChannels?.value != null) {
return _buildList(context, _futureChannels!.value!);
} else if (snapshot.connectionState == ConnectionState.done && snapshot.hasError) {
return Text('Error: ${snapshot.error}'); //TODO better error display
} else if (snapshot.connectionState == ConnectionState.done) {
return _buildList(context, snapshot.data!);
} else {
return Center(child: CircularProgressIndicator());
}
}),
);
}(),
),
actions: <Widget>[
TextButton(
style: TextButton.styleFrom(textStyle: Theme.of(context).textTheme.labelLarge),
child: const Text('Apply'),
onPressed: () {
onOkay();
},
),
],
);
}
void onOkay() {
Navigator.of(context).pop();
final chiplets = _selectedEntries
.map((e) => MessageFilterChiplet(
label: _futureChannels?.get()?.map((e) => e as Channel?).firstWhere((p) => p?.channelID == e, orElse: () => null)?.displayName ?? '???',
value: e,
type: MessageFilterChipletType.channel,
))
.toList();
AppEvents().notifyFilterListeners([MessageFilterChipletType.channel], chiplets);
}
Widget _buildList(BuildContext context, List<Channel> list) {
return ListView.builder(
shrinkWrap: true,
itemBuilder: (builder, index) {
final channel = list[index];
return ListTile(
title: Text(channel.displayName),
leading: Icon(_selectedEntries.contains(channel.channelID) ? Icons.check_box : Icons.check_box_outline_blank, color: Theme.of(context).primaryColor),
onTap: () => toggleEntry(channel.channelID),
visualDensity: VisualDensity(vertical: -4),
);
},
itemCount: list.length,
);
}
}

View File

@@ -0,0 +1,114 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:simplecloudnotifier/api/api_client.dart';
import 'package:simplecloudnotifier/models/keytoken.dart';
import 'package:simplecloudnotifier/pages/message_list/message_filter_chiplet.dart';
import 'package:simplecloudnotifier/state/app_auth.dart';
import 'package:simplecloudnotifier/state/app_events.dart';
import 'package:simplecloudnotifier/types/immediate_future.dart';
class FilterModalKeytoken extends StatefulWidget {
@override
_FilterModalKeytokenState createState() => _FilterModalKeytokenState();
}
class _FilterModalKeytokenState extends State<FilterModalKeytoken> {
Set<String> _selectedEntries = {};
late ImmediateFuture<List<KeyToken>>? _futureKeyTokens;
@override
void initState() {
super.initState();
_futureKeyTokens = null;
_futureKeyTokens = ImmediateFuture.ofFuture(() async {
final userAcc = Provider.of<AppAuth>(context, listen: false);
if (!userAcc.isAuth()) throw new Exception('not logged in');
final toks = await APIClient.getKeyTokenList(userAcc);
return toks;
}());
}
void toggleEntry(String senderID) {
setState(() {
if (_selectedEntries.contains(senderID)) {
_selectedEntries.remove(senderID);
} else {
_selectedEntries.add(senderID);
}
});
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Senders'),
content: Container(
width: 9000,
height: 9000,
child: () {
if (_futureKeyTokens == null) {
return Center(child: CircularProgressIndicator());
}
return FutureBuilder(
future: _futureKeyTokens!.future,
builder: ((context, snapshot) {
if (_futureKeyTokens?.value != null) {
return _buildList(context, _futureKeyTokens!.value!);
} else if (snapshot.connectionState == ConnectionState.done && snapshot.hasError) {
return Text('Error: ${snapshot.error}'); //TODO better error display
} else if (snapshot.connectionState == ConnectionState.done) {
return _buildList(context, snapshot.data!);
} else {
return Center(child: CircularProgressIndicator());
}
}),
);
}(),
),
actions: <Widget>[
TextButton(
style: TextButton.styleFrom(textStyle: Theme.of(context).textTheme.labelLarge),
child: const Text('Apply'),
onPressed: () {
onOkay();
},
),
],
);
}
void onOkay() {
Navigator.of(context).pop();
final chiplets = _selectedEntries
.map((e) => MessageFilterChiplet(
label: _futureKeyTokens?.get()?.map((e) => e as KeyToken?).firstWhere((p) => p?.keytokenID == e, orElse: () => null)?.name ?? '???',
value: e,
type: MessageFilterChipletType.sender,
))
.toList();
AppEvents().notifyFilterListeners([MessageFilterChipletType.sender], chiplets);
}
Widget _buildList(BuildContext context, List<KeyToken> list) {
return ListView.builder(
shrinkWrap: true,
itemBuilder: (builder, index) {
final sender = list[index];
return ListTile(
title: Text(sender.name),
leading: Icon(_selectedEntries.contains(sender.keytokenID) ? Icons.check_box : Icons.check_box_outline_blank, color: Theme.of(context).primaryColor),
onTap: () => toggleEntry(sender.keytokenID),
visualDensity: VisualDensity(vertical: -4),
);
},
itemCount: list.length,
);
}
}

View File

@@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
import 'package:simplecloudnotifier/pages/message_list/message_filter_chiplet.dart';
import 'package:simplecloudnotifier/state/app_events.dart';
class FilterModalPriority extends StatefulWidget {
@override
_FilterModalPriorityState createState() => _FilterModalPriorityState();
}
class _FilterModalPriorityState extends State<FilterModalPriority> {
Set<int> _selectedEntries = {};
Map<int, (String, String)> _texts = {
0: ('Low (0)', 'Low'),
1: ('Normal (1)', 'Normal'),
2: ('High (2)', 'High'),
};
void toggleEntry(int entry) {
setState(() {
if (_selectedEntries.contains(entry)) {
_selectedEntries.remove(entry);
} else {
_selectedEntries.add(entry);
}
});
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Priority'),
content: Container(
width: 0,
height: 200,
child: ListView.builder(
shrinkWrap: true,
itemBuilder: (builder, index) {
return ListTile(
title: Text(_texts[index]?.$1 ?? '???'),
leading: Icon(_selectedEntries.contains(index) ? Icons.check_box : Icons.check_box_outline_blank, color: Theme.of(context).primaryColor),
onTap: () => toggleEntry(index),
);
},
itemCount: 3,
),
),
actions: <Widget>[
TextButton(
style: TextButton.styleFrom(textStyle: Theme.of(context).textTheme.labelLarge),
child: const Text('Apply'),
onPressed: () {
onOkay();
},
),
],
);
}
void onOkay() {
Navigator.of(context).pop();
final chiplets = _selectedEntries.map((e) => MessageFilterChiplet(label: _texts[e]?.$2 ?? '???', value: e, type: MessageFilterChipletType.priority)).toList();
AppEvents().notifyFilterListeners([MessageFilterChipletType.priority], chiplets);
}
}

View File

@@ -0,0 +1,113 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:simplecloudnotifier/api/api_client.dart';
import 'package:simplecloudnotifier/pages/message_list/message_filter_chiplet.dart';
import 'package:simplecloudnotifier/state/app_auth.dart';
import 'package:simplecloudnotifier/state/app_events.dart';
import 'package:simplecloudnotifier/types/immediate_future.dart';
class FilterModalSendername extends StatefulWidget {
@override
_FilterModalSendernameState createState() => _FilterModalSendernameState();
}
class _FilterModalSendernameState extends State<FilterModalSendername> {
Set<String> _selectedEntries = {};
late ImmediateFuture<List<String>>? _futureSenders;
@override
void initState() {
super.initState();
_futureSenders = null;
_futureSenders = ImmediateFuture.ofFuture(() async {
final userAcc = Provider.of<AppAuth>(context, listen: false);
if (!userAcc.isAuth()) throw new Exception('not logged in');
final senders = (await APIClient.getSenderNameList(userAcc)).map((p) => p.name).toList();
return senders;
}());
}
void toggleEntry(String senderID) {
setState(() {
if (_selectedEntries.contains(senderID)) {
_selectedEntries.remove(senderID);
} else {
_selectedEntries.add(senderID);
}
});
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Senders'),
content: Container(
width: 9000,
height: 9000,
child: () {
if (_futureSenders == null) {
return Center(child: CircularProgressIndicator());
}
return FutureBuilder(
future: _futureSenders!.future,
builder: ((context, snapshot) {
if (_futureSenders?.value != null) {
return _buildList(context, _futureSenders!.value!);
} else if (snapshot.connectionState == ConnectionState.done && snapshot.hasError) {
return Text('Error: ${snapshot.error}'); //TODO better error display
} else if (snapshot.connectionState == ConnectionState.done) {
return _buildList(context, snapshot.data!);
} else {
return Center(child: CircularProgressIndicator());
}
}),
);
}(),
),
actions: <Widget>[
TextButton(
style: TextButton.styleFrom(textStyle: Theme.of(context).textTheme.labelLarge),
child: const Text('Apply'),
onPressed: () {
onOkay();
},
),
],
);
}
void onOkay() {
Navigator.of(context).pop();
final chiplets = _selectedEntries
.map((e) => MessageFilterChiplet(
label: e,
value: e,
type: MessageFilterChipletType.sender,
))
.toList();
AppEvents().notifyFilterListeners([MessageFilterChipletType.sender], chiplets);
}
Widget _buildList(BuildContext context, List<String> list) {
return ListView.builder(
shrinkWrap: true,
itemBuilder: (builder, index) {
final sender = list[index];
return ListTile(
title: Text(sender),
leading: Icon(_selectedEntries.contains(sender) ? Icons.check_box : Icons.check_box_outline_blank, color: Theme.of(context).primaryColor),
onTap: () => toggleEntry(sender),
visualDensity: VisualDensity(vertical: -4),
);
},
itemCount: list.length,
);
}
}

View File

@@ -0,0 +1,43 @@
import 'package:flutter/material.dart';
class FilterModalTime extends StatefulWidget {
@override
_FilterModalTimeState createState() => _FilterModalTimeState();
}
class _FilterModalTimeState extends State<FilterModalTime> {
DateTime? _tsBefore = null;
DateTime? _tsAfter = null;
@override
void initState() {
super.initState();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Timerange'),
content: Container(
width: 9000,
height: 9000,
child: Placeholder(),
),
actions: <Widget>[
TextButton(
style: TextButton.styleFrom(textStyle: Theme.of(context).textTheme.labelLarge),
child: const Text('Apply'),
onPressed: () {
onOkay();
},
),
],
);
}
void onOkay() {
Navigator.of(context).pop();
//TODO
}
}

View File

@@ -2,14 +2,20 @@ import 'dart:io';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:provider/provider.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:simplecloudnotifier/api/api_client.dart';
import 'package:simplecloudnotifier/models/channel.dart';
import 'package:simplecloudnotifier/models/client.dart';
import 'package:simplecloudnotifier/models/message.dart';
import 'package:simplecloudnotifier/models/scn_message.dart';
import 'package:simplecloudnotifier/nav_layout.dart';
import 'package:simplecloudnotifier/pages/channel_view/channel_view.dart';
import 'package:simplecloudnotifier/pages/message_view/message_view.dart';
import 'package:simplecloudnotifier/settings/app_settings.dart';
import 'package:simplecloudnotifier/state/app_bar_state.dart';
import 'package:simplecloudnotifier/state/app_events.dart';
import 'package:simplecloudnotifier/state/app_theme.dart';
import 'package:simplecloudnotifier/state/application_log.dart';
import 'package:simplecloudnotifier/state/fb_message.dart';
@@ -17,7 +23,9 @@ import 'package:simplecloudnotifier/state/globals.dart';
import 'package:simplecloudnotifier/state/request_log.dart';
import 'package:simplecloudnotifier/state/app_auth.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:simplecloudnotifier/state/scn_data_cache.dart';
import 'package:simplecloudnotifier/utils/navi.dart';
import 'package:simplecloudnotifier/utils/notifier.dart';
import 'package:toastification/toastification.dart';
import 'firebase_options.dart';
@@ -39,20 +47,10 @@ void main() async {
Hive.registerAdapter(SCNRequestAdapter());
Hive.registerAdapter(SCNLogAdapter());
Hive.registerAdapter(SCNLogLevelAdapter());
Hive.registerAdapter(MessageAdapter());
Hive.registerAdapter(SCNMessageAdapter());
Hive.registerAdapter(ChannelAdapter());
Hive.registerAdapter(FBMessageAdapter());
print('[INIT] Load Hive<scn-requests>...');
try {
await Hive.openBox<SCNRequest>('scn-requests');
} catch (exc, trace) {
Hive.deleteBoxFromDisk('scn-requests');
await Hive.openBox<SCNRequest>('scn-requests');
ApplicationLog.error('Failed to open Hive-Box: scn-requests: ' + exc.toString(), trace: trace);
}
print('[INIT] Load Hive<scn-logs>...');
try {
@@ -61,16 +59,29 @@ void main() async {
Hive.deleteBoxFromDisk('scn-logs');
await Hive.openBox<SCNLog>('scn-logs');
ApplicationLog.error('Failed to open Hive-Box: scn-logs: ' + exc.toString(), trace: trace);
ApplicationLog.writeRawFailure('Failed to open Hive-Box: scn-logs', {'error': exc.toString(), 'trace': trace});
}
print('[INIT] Load Hive<scn-requests>...');
try {
await Hive.openBox<SCNRequest>('scn-requests');
} catch (exc, trace) {
Hive.deleteBoxFromDisk('scn-requests');
await Hive.openBox<SCNRequest>('scn-requests');
ApplicationLog.error('Failed to open Hive-Box: scn-requests: ' + exc.toString(), trace: trace);
ApplicationLog.writeRawFailure('Failed to open Hive-Box: scn-requests', {'error': exc.toString(), 'trace': trace});
}
print('[INIT] Load Hive<scn-message-cache>...');
try {
await Hive.openBox<Message>('scn-message-cache');
await Hive.openBox<SCNMessage>('scn-message-cache');
} catch (exc, trace) {
Hive.deleteBoxFromDisk('scn-message-cache');
await Hive.openBox<Message>('scn-message-cache');
await Hive.openBox<SCNMessage>('scn-message-cache');
ApplicationLog.error('Failed to open Hive-Box: scn-message-cache' + exc.toString(), trace: trace);
ApplicationLog.writeRawFailure('Failed to open Hive-Box: scn-message-cache', {'error': exc.toString(), 'trace': trace});
}
print('[INIT] Load Hive<scn-channel-cache>...');
@@ -81,6 +92,7 @@ void main() async {
Hive.deleteBoxFromDisk('scn-channel-cache');
await Hive.openBox<Channel>('scn-channel-cache');
ApplicationLog.error('Failed to open Hive-Box: scn-channel-cache' + exc.toString(), trace: trace);
ApplicationLog.writeRawFailure('Failed to open Hive-Box: scn-channel-cache', {'error': exc.toString(), 'trace': trace});
}
print('[INIT] Load Hive<scn-fb-messages>...');
@@ -91,6 +103,7 @@ void main() async {
Hive.deleteBoxFromDisk('scn-fb-messages');
await Hive.openBox<FBMessage>('scn-fb-messages');
ApplicationLog.error('Failed to open Hive-Box: scn-fb-messages' + exc.toString(), trace: trace);
ApplicationLog.writeRawFailure('Failed to open Hive-Box: scn-fb-messages', {'error': exc.toString(), 'trace': trace});
}
print('[INIT] Load AppAuth...');
@@ -98,20 +111,21 @@ void main() async {
final appAuth = AppAuth(); // ensure UserAccount is loaded
if (appAuth.isAuth()) {
// load user+client in background
() async {
try {
print('[INIT] Load User...');
await appAuth.loadUser();
//TODO fallback to cached user (perhaps event use cached client (if exists) directly and only update/load in background)
} catch (exc, trace) {
ApplicationLog.error('Failed to load user (on startup): ' + exc.toString(), trace: trace);
ApplicationLog.error('Failed to load user (background load on startup): ' + exc.toString(), trace: trace);
ApplicationLog.writeRawFailure('Failed to load user (background load on startup)', {'error': exc.toString(), 'trace': trace});
}
try {
print('[INIT] Load Client...');
await appAuth.loadClient();
//TODO fallback to cached client (perhaps event use cached client (if exists) directly and only update/load in background)
} catch (exc, trace) {
ApplicationLog.error('Failed to load user (on startup): ' + exc.toString(), trace: trace);
ApplicationLog.error('Failed to load user (background load on startup): ' + exc.toString(), trace: trace);
ApplicationLog.writeRawFailure('Failed to load user (background load on startup)', {'error': exc.toString(), 'trace': trace});
}
}();
}
if (!Platform.isLinux) {
@@ -147,6 +161,46 @@ void main() async {
print('[INIT] Skip Firebase init (Platform == Linux)...');
}
print('[INIT] Load Notifications...');
final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();
final flutterLocalNotificationsPluginImpl = flutterLocalNotificationsPlugin.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>();
if (flutterLocalNotificationsPluginImpl == null) {
ApplicationLog.error('Failed to get AndroidFlutterLocalNotificationsPlugin', trace: StackTrace.current);
} else {
flutterLocalNotificationsPluginImpl.requestNotificationsPermission();
final initializationSettingsAndroid = AndroidInitializationSettings('ic_notification_white');
final initializationSettingsDarwin = DarwinInitializationSettings(
requestAlertPermission: true,
requestBadgePermission: true,
requestSoundPermission: true,
onDidReceiveLocalNotification: _receiveLocalDarwinNotification,
notificationCategories: getDarwinNotificationCategories(),
);
final initializationSettingsLinux = LinuxInitializationSettings(defaultActionName: 'Open notification');
final initializationSettings = InitializationSettings(
android: initializationSettingsAndroid,
iOS: initializationSettingsDarwin,
linux: initializationSettingsLinux,
);
flutterLocalNotificationsPlugin.initialize(
initializationSettings,
onDidReceiveNotificationResponse: _receiveLocalNotification,
onDidReceiveBackgroundNotificationResponse: _notificationTapBackground,
);
final appLaunchNotification = await flutterLocalNotificationsPlugin.getNotificationAppLaunchDetails();
if (appLaunchNotification != null) {
// Use has launched SCN by clicking on a loca notifiaction, if it was a summary or message notifiaction open the corresponding screen
// This is android only
//TODO same on iOS, somehow??
ApplicationLog.info('App launched by notification: ${appLaunchNotification.notificationResponse?.id}');
_handleNotificationClickAction(appLaunchNotification.notificationResponse?.payload, Duration(milliseconds: 600));
}
}
ApplicationLog.debug('[INIT] Application started');
runApp(
@@ -155,6 +209,7 @@ void main() async {
ChangeNotifierProvider(create: (context) => AppAuth(), lazy: false),
ChangeNotifierProvider(create: (context) => AppTheme(), lazy: false),
ChangeNotifierProvider(create: (context) => AppBarState(), lazy: false),
ChangeNotifierProvider(create: (context) => AppSettings(), lazy: false),
],
child: SCNApp(),
),
@@ -164,16 +219,19 @@ void main() async {
class SCNApp extends StatelessWidget {
SCNApp({super.key});
static var materialKey = GlobalKey<NavigatorState>();
@override
Widget build(BuildContext context) {
return ToastificationWrapper(
config: ToastificationConfig(
itemWidth: 440,
marginBuilder: (alignment) => EdgeInsets.symmetric(vertical: 64),
marginBuilder: (context, alignment) => EdgeInsets.symmetric(vertical: 64),
animationDuration: Duration(milliseconds: 200),
),
child: Consumer<AppTheme>(
builder: (context, appTheme, child) => MaterialApp(
navigatorKey: SCNApp.materialKey,
title: 'SimpleCloudNotifier',
navigatorObservers: [Navi.routeObserver, Navi.modalRouteObserver],
theme: ThemeData(
@@ -188,6 +246,12 @@ class SCNApp extends StatelessWidget {
}
}
@pragma('vm:entry-point')
void _notificationTapBackground(NotificationResponse notificationResponse) {
// I think only iOS triggers this, TODO
ApplicationLog.info('Received local notification<vm:entry-point>: ${notificationResponse.id}');
}
void setFirebaseToken(String fcmToken) async {
final acc = AppAuth();
@@ -224,18 +288,133 @@ void setFirebaseToken(String fcmToken) async {
}
}
@pragma('vm:entry-point')
Future<void> _onBackgroundMessage(RemoteMessage message) async {
// a firebase message was received while the app was in the background or terminated
await _receiveMessage(message, false);
}
@pragma('vm:entry-point')
void _onForegroundMessage(RemoteMessage message) {
// a firebase message was received while the app was in the foreground
_receiveMessage(message, true);
}
Future<void> _receiveMessage(RemoteMessage message, bool foreground) async {
// ensure init
Hive.openBox<SCNLog>('scn-logs');
try {
// ensure globals init
if (!Globals().isInitialized) {
print('[LATE-INIT] Init Globals() - to ensure working _receiveMessage($foreground)...');
await Globals().init();
}
// ensure hive init
if (!Hive.isBoxOpen('scn-logs')) {
print('[LATE-INIT] Init Hive - to ensure working _receiveMessage($foreground)...');
await Hive.initFlutter();
Hive.registerAdapter(SCNRequestAdapter());
Hive.registerAdapter(SCNLogAdapter());
Hive.registerAdapter(SCNLogLevelAdapter());
Hive.registerAdapter(SCNMessageAdapter());
Hive.registerAdapter(ChannelAdapter());
Hive.registerAdapter(FBMessageAdapter());
}
print('[LATE-INIT] Ensure hive boxes are open for _receiveMessage($foreground)...');
await Hive.openBox<SCNLog>('scn-logs');
await Hive.openBox<FBMessage>('scn-fb-messages');
await Hive.openBox<SCNMessage>('scn-message-cache');
await Hive.openBox<SCNRequest>('scn-requests');
} catch (exc, trace) {
ApplicationLog.writeRawFailure('Failed to init hive', {'exception': exc.toString(), 'trace': trace.toString(), 'data': message, 'foreground': foreground});
ApplicationLog.error('Failed to init hive:' + exc.toString(), trace: trace);
Notifier.showLocalNotification("", "@ERROR", "@ERROR", 'Error Channel', "Error", "Failed to receive SCN message (init failed)", null);
return;
}
ApplicationLog.info('Received FB message (${foreground ? 'foreground' : 'background'}): ${message.messageId ?? 'NULL'}');
String scn_msg_id;
try {
scn_msg_id = message.data['scn_msg_id'] as String;
final timestamp = DateTime.fromMillisecondsSinceEpoch(int.parse(message.data['timestamp'] as String) * 1000);
final title = message.data['title'] as String;
final channel = message.data['channel'] as String;
final channel_id = message.data['channel_id'] as String;
final body = message.data['body'] as String;
Notifier.showLocalNotification(scn_msg_id, channel_id, channel, 'Channel: ${channel}', title, body, timestamp);
} catch (exc, trace) {
ApplicationLog.writeRawFailure('Failed to decode received FB message', {'exception': exc.toString(), 'trace': trace.toString(), 'data': message, 'foreground': foreground});
ApplicationLog.error('Failed to decode received FB message' + exc.toString(), trace: trace);
Notifier.showLocalNotification("", "@ERROR", "@ERROR", 'Error Channel', "Error", "Failed to receive SCN message (decode failed)", null);
return;
}
try {
FBMessageLog.insert(message);
} catch (exc, trace) {
ApplicationLog.writeRawFailure('Failed to persist received FB message', {'exception': exc.toString(), 'trace': trace.toString(), 'data': message, 'foreground': foreground});
ApplicationLog.error('Failed to persist received FB message' + exc.toString(), trace: trace);
Notifier.showLocalNotification("", "@ERROR", "@ERROR", 'Error Channel', "Error", "Failed to receive SCN message (persist failed)", null);
return;
}
try {
final msg = await APIClient.getMessage(AppAuth(), scn_msg_id);
SCNDataCache().addToMessageCache([msg]);
if (foreground) AppEvents().notifyMessageReceivedListeners(msg);
} catch (exc, trace) {
ApplicationLog.writeRawFailure('Failed to query+persist message', {'exception': exc.toString(), 'trace': trace.toString(), 'data': message, 'foreground': foreground});
ApplicationLog.error('Failed to query+persist message: ' + exc.toString(), trace: trace);
return;
}
}
void _receiveLocalDarwinNotification(int id, String? title, String? body, String? payload) {
//TODO iOS?
ApplicationLog.info('Received local notification<darwin>: $id -> [$title]');
}
void _receiveLocalNotification(NotificationResponse details) {
// User has tapped a flutter_local notification, while the app was running
ApplicationLog.info('Tapped local notification: [[${details.id} | ${details.actionId} | ${details.input} | ${details.notificationResponseType} | ${details.payload}]]');
_handleNotificationClickAction(details.payload, Duration.zero);
}
void _handleNotificationClickAction(String? payload, Duration delay) {
final parts = payload?.split('\n') ?? [];
if (parts.length == 4 && parts[0] == '@SCN_MESSAGE') {
final messageID = parts[1];
() async {
await Future.delayed(delay, () {});
SchedulerBinding.instance.addPostFrameCallback((_) {
ApplicationLog.info('Handle notification action @SCN_MESSAGE --> ${messageID}');
Navi.push(SCNApp.materialKey.currentContext!, () => MessageViewPage(messageID: messageID, preloadedData: null));
});
}();
} else if (parts.length == 3 && parts[0] == '@SCN_MESSAGE_SUMMARY') {
final channelID = parts[1];
() async {
await Future.delayed(delay, () {});
SchedulerBinding.instance.addPostFrameCallback((_) {
ApplicationLog.info('Handle notification action @SCN_MESSAGE_SUMMARY --> ${channelID}');
Navi.push(SCNApp.materialKey.currentContext!, () => ChannelViewPage(channelID: channelID, preloadedData: null, needsReload: null));
});
}();
}
}
List<DarwinNotificationCategory> getDarwinNotificationCategories() {
return <DarwinNotificationCategory>[
//TODO ?!?
];
}

View File

@@ -1,7 +1,7 @@
class APIError {
final bool success;
final int error;
final String errhighlight;
final int errhighlight;
final String message;
static final MISSING_UID = 1101;
@@ -66,8 +66,8 @@ class APIError {
factory APIError.fromJson(Map<String, dynamic> json) {
return APIError(
success: json['success'] as bool,
error: (json['error'] as double).toInt(),
errhighlight: json['errhighlight'] as String,
error: (json['error'] as num).toInt(),
errhighlight: (json['errhighlight'] as num).toInt(),
message: json['message'] as String,
);
}

View File

@@ -12,11 +12,11 @@ class Channel extends HiveObject implements FieldDebuggable {
@HiveField(10)
final String ownerUserID;
@HiveField(11)
final String internalName;
final String internalName; // = InternalName, used for sending, normalized, cannot be changed
@HiveField(12)
final String displayName;
final String displayName; // = DisplayName, used for display purposes, can be changed, initially equals InternalName
@HiveField(13)
final String? descriptionName;
final String? descriptionName; // = DescriptionName, (optional), longer description text, initally nil
@HiveField(14)
final String? subscribeKey;
@HiveField(15)
@@ -70,11 +70,21 @@ class Channel extends HiveObject implements FieldDebuggable {
('messagesSent', '${this.messagesSent}'),
];
}
ChannelPreview toPreview() {
return ChannelPreview(
channelID: this.channelID,
ownerUserID: this.ownerUserID,
internalName: this.internalName,
displayName: this.displayName,
descriptionName: this.descriptionName,
);
}
}
class ChannelWithSubscription {
final Channel channel;
final Subscription subscription;
final Subscription? subscription;
ChannelWithSubscription({
required this.channel,
@@ -84,7 +94,7 @@ class ChannelWithSubscription {
factory ChannelWithSubscription.fromJson(Map<String, dynamic> json) {
return ChannelWithSubscription(
channel: Channel.fromJson(json),
subscription: Subscription.fromJson(json['subscription'] as Map<String, dynamic>),
subscription: json['subscription'] == null ? null : Subscription.fromJson(json['subscription'] as Map<String, dynamic>),
);
}

View File

@@ -32,6 +32,19 @@ class Client {
);
}
Map<String, dynamic> toJson() {
return {
'client_id': clientID,
'user_id': userID,
'type': type,
'fcm_token': fcmToken,
'timestamp_created': timestampCreated,
'agent_model': agentModel,
'agent_version': agentVersion,
'name': name,
};
}
static List<Client> fromJsonArray(List<dynamic> jsonArr) {
return jsonArr.map<Client>((e) => Client.fromJson(e as Map<String, dynamic>)).toList();
}

View File

@@ -0,0 +1,75 @@
import 'package:simplecloudnotifier/models/channel.dart';
enum ScanResultMode { ChannelSubscribe, MessageSend, Channel }
abstract class ScanResult {
ScanResultMode get mode;
static ScanResult? parse(String v) {
var lines = v.split('\n');
if (lines.length == 1 && lines[0].startsWith('https://simplecloudnotifier.de?')) {
final v = Uri.tryParse(lines[0]);
if (v != null && v.queryParameters.containsKey('preset_user_id') && v.queryParameters.containsKey('preset_user_key')) {
return ScanResultMessageSend(userID: v.queryParameters['preset_user_id']!, userKey: v.queryParameters['preset_user_key']);
}
if (v != null && v.queryParameters.containsKey('preset_user_id') && v.queryParameters.containsKey('preset_user_key')) {
return ScanResultMessageSend(userID: v.queryParameters['preset_user_id']!, userKey: null);
}
}
if (lines.length == 6 && lines[0] == '@scn.channel.subscribe' && lines[1] == 'v1') {
return ScanResultChannelSubscribe(channelDisplayName: lines[2], ownerUserID: lines[3], channelID: lines[4], subscribeKey: lines[5]);
}
if (lines.length == 5 && lines[0] == '@scn.channel' && lines[1] == 'v1') {
if (lines.length != 4) return null;
return ScanResultChannel(channelDisplayName: lines[2], ownerUserID: lines[3], channelID: lines[4]);
}
return null;
}
static String createChannelQR(Channel channel) {
return '@scn.channel' + '\n' + "v1" + '\n' + channel.displayName + '\n' + channel.ownerUserID + '\n' + channel.channelID;
}
static String createChannelSubscribeQR(Channel channel, String subscribeKey) {
return '@scn.channel.subscribe' + '\n' + "v1" + '\n' + channel.displayName + '\n' + channel.ownerUserID + '\n' + channel.channelID + '\n' + subscribeKey;
}
}
class ScanResultMessageSend extends ScanResult {
final String userID;
final String? userKey;
ScanResultMessageSend({required this.userID, required this.userKey});
@override
ScanResultMode get mode => ScanResultMode.MessageSend;
}
class ScanResultChannel extends ScanResult {
final String channelDisplayName;
final String ownerUserID;
final String channelID;
ScanResultChannel({required this.channelDisplayName, required this.ownerUserID, required this.channelID});
@override
ScanResultMode get mode => ScanResultMode.Channel;
}
class ScanResultChannelSubscribe extends ScanResult {
final String channelDisplayName;
final String ownerUserID;
final String channelID;
final String subscribeKey;
ScanResultChannelSubscribe({required this.channelDisplayName, required this.ownerUserID, required this.channelID, required this.subscribeKey});
@override
ScanResultMode get mode => ScanResultMode.ChannelSubscribe;
}

View File

@@ -1,10 +1,10 @@
import 'package:hive_flutter/hive_flutter.dart';
import 'package:simplecloudnotifier/state/interfaces.dart';
part 'message.g.dart';
part 'scn_message.g.dart';
@HiveType(typeId: 105)
class Message extends HiveObject implements FieldDebuggable {
class SCNMessage extends HiveObject implements FieldDebuggable {
@HiveField(0)
final String messageID;
@@ -33,7 +33,7 @@ class Message extends HiveObject implements FieldDebuggable {
@HiveField(21)
final bool trimmed;
Message({
SCNMessage({
required this.messageID,
required this.senderUserID,
required this.channelInternalName,
@@ -49,8 +49,8 @@ class Message extends HiveObject implements FieldDebuggable {
required this.trimmed,
});
factory Message.fromJson(Map<String, dynamic> json) {
return Message(
factory SCNMessage.fromJson(Map<String, dynamic> json) {
return SCNMessage(
messageID: json['message_id'] as String,
senderUserID: json['sender_user_id'] as String,
channelInternalName: json['channel_internal_name'] as String,
@@ -67,10 +67,10 @@ class Message extends HiveObject implements FieldDebuggable {
);
}
static (String, List<Message>) fromPaginatedJsonArray(Map<String, dynamic> data, String keyMessages, String keyToken) {
static (String, List<SCNMessage>) fromPaginatedJsonArray(Map<String, dynamic> data, String keyMessages, String keyToken) {
final npt = data[keyToken] as String;
final messages = (data[keyMessages] as List<dynamic>).map<Message>((e) => Message.fromJson(e as Map<String, dynamic>)).toList();
final messages = (data[keyMessages] as List<dynamic>).map<SCNMessage>((e) => SCNMessage.fromJson(e as Map<String, dynamic>)).toList();
return (npt, messages);
}

View File

@@ -1,22 +1,22 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'message.dart';
part of 'scn_message.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class MessageAdapter extends TypeAdapter<Message> {
class SCNMessageAdapter extends TypeAdapter<SCNMessage> {
@override
final int typeId = 105;
@override
Message read(BinaryReader reader) {
SCNMessage read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return Message(
return SCNMessage(
messageID: fields[0] as String,
senderUserID: fields[10] as String,
channelInternalName: fields[11] as String,
@@ -34,7 +34,7 @@ class MessageAdapter extends TypeAdapter<Message> {
}
@override
void write(BinaryWriter writer, Message obj) {
void write(BinaryWriter writer, SCNMessage obj) {
writer
..writeByte(13)
..writeByte(0)
@@ -71,7 +71,7 @@ class MessageAdapter extends TypeAdapter<Message> {
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is MessageAdapter &&
other is SCNMessageAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,26 @@
class SenderNameStatistics {
final String name;
final String lastTimestamp;
final String firstTimestamp;
final int count;
const SenderNameStatistics({
required this.name,
required this.lastTimestamp,
required this.firstTimestamp,
required this.count,
});
factory SenderNameStatistics.fromJson(Map<String, dynamic> json) {
return SenderNameStatistics(
name: json['name'] as String,
lastTimestamp: json['last_timestamp'] as String,
firstTimestamp: json['first_timestamp'] as String,
count: json['count'] as int,
);
}
static List<SenderNameStatistics> fromJsonArray(List<dynamic> jsonArr) {
return jsonArr.map<SenderNameStatistics>((e) => SenderNameStatistics.fromJson(e as Map<String, dynamic>)).toList();
}
}

View File

@@ -63,6 +63,33 @@ class User {
maxUserMessageIDLength: json['max_user_message_id_length'] as int,
);
}
Map<String, dynamic> toJson() {
return {
'user_id': userID,
'username': username,
'timestamp_created': timestampCreated,
'timestamp_lastread': timestampLastRead,
'timestamp_lastsent': timestampLastSent,
'messages_sent': messagesSent,
'quota_used': quotaUsed,
'quota_remaining': quotaRemaining,
'quota_max': quotaPerDay,
'is_pro': isPro,
'default_channel': defaultChannel,
'max_body_size': maxBodySize,
'max_title_length': maxTitleLength,
'default_priority': defaultPriority,
'max_channel_name_length': maxChannelNameLength,
'max_channel_description_length': maxChannelDescriptionLength,
'max_sender_name_length': maxSenderNameLength,
'max_user_message_id_length': maxUserMessageIDLength,
};
}
UserPreview toPreview() {
return UserPreview(userID: userID, username: username);
}
}
class UserWithClientsAndKeys {

View File

@@ -59,8 +59,7 @@ class _SCNNavLayoutState extends State<SCNNavLayout> {
return Scaffold(
appBar: SCNAppBar(
title: null,
showDebug: true,
showSearch: _selectedIndex == 0 || _selectedIndex == 1,
showSearch: _selectedIndex == 0,
showShare: false,
showThemeSwitch: true,
),
@@ -77,6 +76,7 @@ class _SCNNavLayoutState extends State<SCNNavLayout> {
bottomNavigationBar: _buildNavBar(context),
floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
floatingActionButton: HidableFAB(
heroTag: 'fab_main',
onPressed: _onFABTapped,
icon: FontAwesomeIcons.solidPaperPlaneTop,
),

View File

@@ -7,11 +7,13 @@ import 'package:provider/provider.dart';
import 'package:simplecloudnotifier/api/api_client.dart';
import 'package:simplecloudnotifier/models/user.dart';
import 'package:simplecloudnotifier/pages/account/login.dart';
import 'package:simplecloudnotifier/pages/channel_list/channel_list_extended.dart';
import 'package:simplecloudnotifier/state/app_bar_state.dart';
import 'package:simplecloudnotifier/state/application_log.dart';
import 'package:simplecloudnotifier/state/globals.dart';
import 'package:simplecloudnotifier/state/app_auth.dart';
import 'package:simplecloudnotifier/types/immediate_future.dart';
import 'package:simplecloudnotifier/utils/dialogs.dart';
import 'package:simplecloudnotifier/utils/navi.dart';
import 'package:simplecloudnotifier/utils/toaster.dart';
import 'package:simplecloudnotifier/utils/ui.dart';
@@ -32,6 +34,7 @@ class _AccountRootPageState extends State<AccountRootPage> {
late ImmediateFuture<int>? futureKeyCount;
late ImmediateFuture<int>? futureChannelAllCount;
late ImmediateFuture<int>? futureChannelSubscribedCount;
late ImmediateFuture<int>? futureSenderNamesCount;
late ImmediateFuture<User>? futureUser;
late AppAuth userAcc;
@@ -87,6 +90,7 @@ class _AccountRootPageState extends State<AccountRootPage> {
futureKeyCount = null;
futureChannelAllCount = null;
futureChannelSubscribedCount = null;
futureSenderNamesCount = null;
if (userAcc.isAuth()) {
futureChannelAllCount = ImmediateFuture.ofFuture(() async {
@@ -119,6 +123,12 @@ class _AccountRootPageState extends State<AccountRootPage> {
return keys.length;
}());
futureSenderNamesCount = ImmediateFuture.ofFuture(() async {
if (!userAcc.isAuth()) throw new Exception('not logged in');
final senders = (await APIClient.getSenderNameList(userAcc)).map((p) => p.name).toList();
return senders.length;
}());
futureUser = ImmediateFuture.ofFuture(userAcc.loadUser(force: false));
}
}
@@ -137,6 +147,7 @@ class _AccountRootPageState extends State<AccountRootPage> {
final subs = await APIClient.getSubscriptionList(userAcc);
final clients = await APIClient.getClientList(userAcc);
final keys = await APIClient.getKeyTokenList(userAcc);
final senderNames = await APIClient.getSenderNameList(userAcc);
final user = await userAcc.loadUser(force: true);
setState(() {
@@ -145,6 +156,7 @@ class _AccountRootPageState extends State<AccountRootPage> {
futureSubscriptionCount = ImmediateFuture.ofValue(subs.length);
futureClientCount = ImmediateFuture.ofValue(clients.length);
futureKeyCount = ImmediateFuture.ofValue(keys.length);
futureSenderNamesCount = ImmediateFuture.ofValue(senderNames.length);
futureUser = ImmediateFuture.ofValue(user);
});
} catch (exc, trace) {
@@ -348,13 +360,15 @@ class _AccountRootPageState extends State<AccountRootPage> {
mainAxisAlignment: MainAxisAlignment.start,
children: [
UI.buttonIconOnly(
onPressed: () {/*TODO*/},
onPressed: _changeUsername,
icon: FontAwesomeIcons.pen,
),
const SizedBox(height: 4),
if (!user.isPro)
UI.buttonIconOnly(
onPressed: () {/*TODO*/},
onPressed: () {
Toaster.info("Not Implemented", "Account Upgrading will be implemented in a later version"); // TODO
},
icon: FontAwesomeIcons.cartCircleArrowUp,
),
],
@@ -368,7 +382,10 @@ class _AccountRootPageState extends State<AccountRootPage> {
_buildNumberCard(context, 'Subscriptions', futureSubscriptionCount, () {/*TODO*/}),
_buildNumberCard(context, 'Clients', futureClientCount, () {/*TODO*/}),
_buildNumberCard(context, 'Keys', futureKeyCount, () {/*TODO*/}),
_buildNumberCard(context, 'Channels', futureChannelSubscribedCount, () {/*TODO*/}),
_buildNumberCard(context, 'Channels', futureChannelSubscribedCount, () {
Navi.push(context, () => ChannelListExtendedPage());
}),
_buildNumberCard(context, 'Sender', futureSenderNamesCount, () {/*TODO*/}),
UI.buttonCard(
context: context,
margin: EdgeInsets.fromLTRB(0, 4, 0, 4),
@@ -488,4 +505,31 @@ class _AccountRootPageState extends State<AccountRootPage> {
void _deleteAccount() async {
//TODO
}
void _changeUsername() async {
final acc = AppAuth();
if (!acc.isAuth()) return;
var newusername = await UIDialogs.showTextInput(context, 'Change your public username', 'Enter new username');
if (newusername == null) return;
newusername = newusername.trim();
if (newusername == '') {
Toaster.error("Error", 'Username cannot be empty');
return;
}
try {
final user = await APIClient.updateUser(acc, acc.userID!, username: newusername);
setState(() {
futureUser = ImmediateFuture.ofValue(user);
});
Toaster.success("Success", 'Username changed');
_backgroundRefresh();
} catch (exc, trace) {
Toaster.error("Error", 'Failed to update username');
ApplicationLog.error('Failed to update username: ' + exc.toString(), trace: trace);
}
}
}

View File

@@ -140,12 +140,14 @@ class _AccountLoginPageState extends State<AccountLoginPage> {
return;
}
if (stokv != "") {
final toks = await APIClient.getKeyTokenByToken(uid, stokv);
if (!toks.allChannels || toks.permissions != 'CS') {
Toaster.error("Error", 'Send token does not have required permissions');
return;
}
}
final user = await APIClient.getUser(DirectTokenSource(uid, atokv), uid);

View File

@@ -1,12 +1,15 @@
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:provider/provider.dart';
import 'package:simplecloudnotifier/api/api_client.dart';
import 'package:simplecloudnotifier/models/channel.dart';
import 'package:simplecloudnotifier/pages/channel_list/channel_scanner.dart';
import 'package:simplecloudnotifier/state/app_bar_state.dart';
import 'package:simplecloudnotifier/state/application_log.dart';
import 'package:simplecloudnotifier/state/app_auth.dart';
import 'package:simplecloudnotifier/pages/channel_list/channel_list_item.dart';
import 'package:simplecloudnotifier/utils/navi.dart';
class ChannelRootPage extends StatefulWidget {
const ChannelRootPage({super.key, required this.isVisiblePage});
@@ -17,11 +20,13 @@ class ChannelRootPage extends StatefulWidget {
State<ChannelRootPage> createState() => _ChannelRootPageState();
}
class _ChannelRootPageState extends State<ChannelRootPage> {
final PagingController<int, Channel> _pagingController = PagingController.fromValue(PagingState(nextPageKey: null, itemList: [], error: null), firstPageKey: 0);
class _ChannelRootPageState extends State<ChannelRootPage> with RouteAware {
final PagingController<int, ChannelWithSubscription> _pagingController = PagingController.fromValue(PagingState(nextPageKey: null, itemList: [], error: null), firstPageKey: 0);
bool _isInitialized = false;
bool _reloadEnqueued = false;
@override
void initState() {
super.initState();
@@ -31,10 +36,17 @@ class _ChannelRootPageState extends State<ChannelRootPage> {
if (widget.isVisiblePage && !_isInitialized) _realInitState();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
Navi.modalRouteObserver.subscribe(this, ModalRoute.of(context)!);
}
@override
void dispose() {
ApplicationLog.debug('ChannelRootPage::dispose');
_pagingController.dispose();
Navi.modalRouteObserver.unsubscribe(this);
super.dispose();
}
@@ -51,6 +63,24 @@ class _ChannelRootPageState extends State<ChannelRootPage> {
}
}
@override
void didPush() {
// ...
}
@override
void didPopNext() {
if (_reloadEnqueued) {
ApplicationLog.debug('[ChannelList::RouteObserver] --> didPopNext (will background-refresh) (_reloadEnqueued == true)');
() async {
_reloadEnqueued = false;
AppBarState().setLoadingIndeterminate(true);
await Future.delayed(const Duration(milliseconds: 500), () {}); // prevents flutter bug where the whole process crashes ?!?
await _backgroundRefresh();
}();
}
}
void _realInitState() {
ApplicationLog.debug('ChannelRootPage::_realInitState');
_pagingController.refresh();
@@ -68,9 +98,9 @@ class _ChannelRootPageState extends State<ChannelRootPage> {
}
try {
final items = (await APIClient.getChannelList(acc, ChannelSelector.all)).map((p) => p.channel).toList();
final items = (await APIClient.getChannelList(acc, ChannelSelector.all)).toList();
items.sort((a, b) => -1 * (a.timestampLastSent ?? '').compareTo(b.timestampLastSent ?? ''));
items.sort((a, b) => -1 * (a.channel.timestampLastSent ?? '').compareTo(b.channel.timestampLastSent ?? ''));
_pagingController.value = PagingState(nextPageKey: null, itemList: items, error: null);
} catch (exc, trace) {
@@ -94,13 +124,17 @@ class _ChannelRootPageState extends State<ChannelRootPage> {
AppBarState().setLoadingIndeterminate(true);
final items = (await APIClient.getChannelList(acc, ChannelSelector.all)).map((p) => p.channel).toList();
final items = (await APIClient.getChannelList(acc, ChannelSelector.all)).toList();
items.sort((a, b) => -1 * (a.timestampLastSent ?? '').compareTo(b.timestampLastSent ?? ''));
items.sort((a, b) => -1 * (a.channel.timestampLastSent ?? '').compareTo(b.channel.timestampLastSent ?? ''));
setState(() {
_pagingController.value = PagingState(nextPageKey: null, itemList: items, error: null);
});
} catch (exc, trace) {
setState(() {
_pagingController.error = exc.toString();
});
ApplicationLog.error('Failed to list channels: ' + exc.toString(), trace: trace);
} finally {
AppBarState().setLoadingIndeterminate(false);
@@ -109,19 +143,40 @@ class _ChannelRootPageState extends State<ChannelRootPage> {
@override
Widget build(BuildContext context) {
return RefreshIndicator(
return Scaffold(
body: RefreshIndicator(
onRefresh: () => Future.sync(
() => _pagingController.refresh(),
),
child: PagedListView<int, Channel>(
child: PagedListView<int, ChannelWithSubscription>(
pagingController: _pagingController,
builderDelegate: PagedChildBuilderDelegate<Channel>(
builderDelegate: PagedChildBuilderDelegate<ChannelWithSubscription>(
itemBuilder: (context, item, index) => ChannelListItem(
channel: item,
onPressed: () {/*TODO*/},
channel: item.channel,
subscription: item.subscription,
mode: ChannelListItemMode.Messages,
onChannelListReloadTrigger: _enqueueReload,
onSubscriptionChanged: (channelID, subscription) {
setState(() {
final idx = _pagingController.itemList?.indexWhere((p) => p.channel.channelID == channelID);
if (idx != null && idx >= 0) _pagingController.itemList![idx] = ChannelWithSubscription(channel: _pagingController.itemList![idx].channel, subscription: subscription);
});
},
),
),
),
),
floatingActionButton: FloatingActionButton(
heroTag: 'fab_channel_list_qr',
onPressed: () {
Navi.push(context, () => ChannelScannerPage());
},
child: const Icon(FontAwesomeIcons.qrcode),
),
);
}
void _enqueueReload() {
_reloadEnqueued = true;
}
}

View File

@@ -0,0 +1,151 @@
import 'package:flutter/material.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:provider/provider.dart';
import 'package:simplecloudnotifier/api/api_client.dart';
import 'package:simplecloudnotifier/components/layout/scaffold.dart';
import 'package:simplecloudnotifier/models/channel.dart';
import 'package:simplecloudnotifier/state/app_bar_state.dart';
import 'package:simplecloudnotifier/state/application_log.dart';
import 'package:simplecloudnotifier/state/app_auth.dart';
import 'package:simplecloudnotifier/pages/channel_list/channel_list_item.dart';
import 'package:simplecloudnotifier/utils/navi.dart';
class ChannelListExtendedPage extends StatefulWidget {
const ChannelListExtendedPage({super.key});
@override
State<ChannelListExtendedPage> createState() => _ChannelListExtendedPageState();
}
class _ChannelListExtendedPageState extends State<ChannelListExtendedPage> with RouteAware {
final PagingController<int, ChannelWithSubscription> _pagingController = PagingController.fromValue(PagingState(nextPageKey: null, itemList: [], error: null), firstPageKey: 0);
bool _reloadEnqueued = false;
@override
void initState() {
super.initState();
_pagingController.addPageRequestListener(_fetchPage);
_pagingController.refresh();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
Navi.modalRouteObserver.subscribe(this, ModalRoute.of(context)!);
}
@override
void dispose() {
ApplicationLog.debug('ChannelRootPage::dispose');
_pagingController.dispose();
Navi.modalRouteObserver.unsubscribe(this);
super.dispose();
}
@override
void didPopNext() {
if (_reloadEnqueued) {
ApplicationLog.debug('[ChannelList::RouteObserver] --> didPopNext (will background-refresh) (_reloadEnqueued == true)');
() async {
_reloadEnqueued = false;
await Future.delayed(const Duration(milliseconds: 500), () {}); // prevents flutter bug where the whole process crashes ?!?
await _backgroundRefresh();
}();
}
}
Future<void> _fetchPage(int pageKey) async {
final acc = Provider.of<AppAuth>(context, listen: false);
ApplicationLog.debug('Start ChannelList::_pagingController::_fetchPage [ ${pageKey} ]');
if (!acc.isAuth()) {
_pagingController.error = 'Not logged in';
return;
}
try {
final items = (await APIClient.getChannelList(acc, ChannelSelector.all)).toList();
items.sort((a, b) => -1 * (a.channel.timestampLastSent ?? '').compareTo(b.channel.timestampLastSent ?? ''));
_pagingController.value = PagingState(nextPageKey: null, itemList: items, error: null);
} catch (exc, trace) {
_pagingController.error = exc.toString();
ApplicationLog.error('Failed to list channels: ' + exc.toString(), trace: trace);
}
}
Future<void> _backgroundRefresh() async {
final acc = Provider.of<AppAuth>(context, listen: false);
ApplicationLog.debug('Start background refresh of channel list');
if (!acc.isAuth()) {
_pagingController.error = 'Not logged in';
return;
}
try {
await Future.delayed(const Duration(seconds: 0), () {}); // this is annoyingly important - otherwise we call setLoadingIndeterminate directly in initStat() and get an exception....
AppBarState().setLoadingIndeterminate(true);
final items = (await APIClient.getChannelList(acc, ChannelSelector.all)).toList();
items.sort((a, b) => -1 * (a.channel.timestampLastSent ?? '').compareTo(b.channel.timestampLastSent ?? ''));
setState(() {
_pagingController.value = PagingState(nextPageKey: null, itemList: items, error: null);
});
} catch (exc, trace) {
setState(() {
_pagingController.error = exc.toString();
});
ApplicationLog.error('Failed to list channels: ' + exc.toString(), trace: trace);
} finally {
AppBarState().setLoadingIndeterminate(false);
}
}
@override
Widget build(BuildContext context) {
return SCNScaffold(
title: "Channels",
showSearch: false,
showShare: false,
child: Padding(
padding: EdgeInsets.fromLTRB(8, 4, 8, 4),
child: RefreshIndicator(
onRefresh: () => Future.sync(
() => _pagingController.refresh(),
),
child: PagedListView<int, ChannelWithSubscription>(
pagingController: _pagingController,
builderDelegate: PagedChildBuilderDelegate<ChannelWithSubscription>(
itemBuilder: (context, item, index) => ChannelListItem(
channel: item.channel,
subscription: item.subscription,
mode: ChannelListItemMode.Extended,
onChannelListReloadTrigger: _enqueueReload,
onSubscriptionChanged: (channelID, subscription) {
setState(() {
final idx = _pagingController.itemList?.indexWhere((p) => p.channel.channelID == channelID);
if (idx != null && idx >= 0) _pagingController.itemList![idx] = ChannelWithSubscription(channel: _pagingController.itemList![idx].channel, subscription: subscription);
});
},
),
),
),
),
),
);
}
void _enqueueReload() {
_reloadEnqueued = true;
}
}

View File

@@ -1,29 +1,48 @@
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import 'package:simplecloudnotifier/api/api_client.dart';
import 'package:simplecloudnotifier/models/channel.dart';
import 'package:simplecloudnotifier/models/message.dart';
import 'package:simplecloudnotifier/models/scn_message.dart';
import 'package:simplecloudnotifier/models/subscription.dart';
import 'package:simplecloudnotifier/pages/channel_message_view/channel_message_view.dart';
import 'package:simplecloudnotifier/pages/channel_view/channel_view.dart';
import 'package:simplecloudnotifier/state/app_auth.dart';
import 'package:simplecloudnotifier/state/application_log.dart';
import 'package:simplecloudnotifier/state/scn_data_cache.dart';
import 'package:simplecloudnotifier/utils/navi.dart';
import 'package:simplecloudnotifier/utils/toaster.dart';
enum ChannelListItemMode {
Messages,
Extended,
}
class ChannelListItem extends StatefulWidget {
static final _dateFormat = DateFormat('yyyy-MM-dd kk:mm');
const ChannelListItem({
required this.channel,
required this.onPressed,
required this.onChannelListReloadTrigger,
required this.onSubscriptionChanged,
required this.subscription,
required this.mode,
super.key,
});
final Channel channel;
final Null Function() onPressed;
final Subscription? subscription;
final void Function() onChannelListReloadTrigger;
final ChannelListItemMode mode;
final void Function(String, Subscription?) onSubscriptionChanged;
@override
State<ChannelListItem> createState() => _ChannelListItemState();
}
class _ChannelListItemState extends State<ChannelListItem> {
Message? lastMessage;
SCNMessage? lastMessage;
@override
void initState() {
@@ -31,9 +50,11 @@ class _ChannelListItemState extends State<ChannelListItem> {
final acc = Provider.of<AppAuth>(context, listen: false);
if (acc.isAuth()) {
if (acc.isAuth() && widget.mode == ChannelListItemMode.Messages) {
lastMessage = SCNDataCache().getMessagesSorted().where((p) => p.channelID == widget.channel.channelID).firstOrNull;
() async {
final (_, channelMessages) = await APIClient.getMessageList(acc, '@start', pageSize: 1, channelIDs: [widget.channel.channelID]);
final (_, channelMessages) = await APIClient.getChannelMessageList(acc, widget.channel.channelID, '@start', pageSize: 1);
setState(() {
lastMessage = channelMessages.firstOrNull;
});
@@ -43,16 +64,25 @@ class _ChannelListItemState extends State<ChannelListItem> {
@override
Widget build(BuildContext context) {
//TODO subscription status
return Card.filled(
margin: EdgeInsets.fromLTRB(0, 4, 0, 4),
shape: BeveledRectangleBorder(borderRadius: BorderRadius.circular(0)),
color: Theme.of(context).cardTheme.color,
child: InkWell(
splashColor: Theme.of(context).splashColor,
onTap: widget.onPressed,
onTap: () {
if (widget.mode == ChannelListItemMode.Messages) {
Navi.push(context, () => ChannelMessageViewPage(channel: widget.channel));
} else {
Navi.push(context, () => ChannelViewPage(channelID: widget.channel.channelID, preloadedData: (widget.channel, widget.subscription), needsReload: widget.onChannelListReloadTrigger));
}
},
child: Padding(
padding: const EdgeInsets.all(8),
child: Row(
children: [
_buildIcon(context),
SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
@@ -74,19 +104,108 @@ class _ChannelListItemState extends State<ChannelListItem> {
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Text(
lastMessage?.title ?? '...',
style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(160)),
),
),
Text(widget.channel.messagesSent.toString(), style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
Expanded(child: (widget.mode == ChannelListItemMode.Messages) ? Text(_preformatTitle(lastMessage), style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(160))) : _buildSubscriptionStateText(context)),
(widget.mode == ChannelListItemMode.Messages) ? Text(widget.channel.messagesSent.toString(), style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)) : Text("", style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
],
),
],
),
),
SizedBox(width: 4),
GestureDetector(
onTap: () {
if (widget.mode == ChannelListItemMode.Messages) {
Navi.push(context, () => ChannelViewPage(channelID: widget.channel.channelID, preloadedData: (widget.channel, widget.subscription), needsReload: widget.onChannelListReloadTrigger));
} else {
Navi.push(context, () => ChannelMessageViewPage(channel: widget.channel));
}
},
child: Padding(
padding: const EdgeInsets.all(8),
child: (widget.mode == ChannelListItemMode.Messages) ? Icon(FontAwesomeIcons.solidSquareInfo, color: Theme.of(context).colorScheme.onPrimaryContainer.withAlpha(128), size: 24) : Icon(FontAwesomeIcons.solidEnvelopes, color: Theme.of(context).colorScheme.onPrimaryContainer.withAlpha(128), size: 24),
),
),
],
),
),
),
);
}
String _preformatTitle(SCNMessage? message) {
if (message == null) return '...';
return message.title.replaceAll('\n', '').replaceAll('\r', '').replaceAll('\t', ' ');
}
Widget _buildIcon(BuildContext context) {
if (widget.subscription == null) {
Widget result = Icon(FontAwesomeIcons.solidSquareDashed, color: Theme.of(context).colorScheme.outline, size: 32); // not-subscribed
result = GestureDetector(onTap: () => _subscribe(), child: result);
return result;
} else if (widget.subscription!.confirmed && widget.channel.ownerUserID == widget.subscription!.subscriberUserID) {
Widget result = Icon(FontAwesomeIcons.solidSquareRss, color: Theme.of(context).colorScheme.onPrimaryContainer, size: 32); // subscribed (own channel)
result = GestureDetector(onTap: () => _unsubscribe(widget.subscription!), child: result);
return result;
} else if (widget.subscription!.confirmed) {
Widget result = Icon(FontAwesomeIcons.solidSquareShareNodes, color: Theme.of(context).colorScheme.onPrimaryContainer, size: 32); // subscribed (foreign channel)
result = GestureDetector(onTap: () => _unsubscribe(widget.subscription!), child: result);
return result;
} else {
Widget result = Icon(FontAwesomeIcons.solidSquareEnvelope, color: Theme.of(context).colorScheme.tertiary, size: 32); // requested
result = GestureDetector(onTap: () => _unsubscribe(widget.subscription!), child: result);
return result;
}
}
Widget _buildSubscriptionStateText(BuildContext context) {
if (widget.subscription == null) {
return Text("", style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(160)));
} else if (widget.subscription!.confirmed && widget.channel.ownerUserID == widget.subscription!.subscriberUserID) {
return Text("subscribed", style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(160)));
} else if (widget.subscription!.confirmed) {
return Text("subscripted (foreign channe)", style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(160)));
} else {
return Text("subscription requested", style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(160)));
}
}
void _subscribe() async {
final acc = AppAuth();
if (acc.isAuth() && widget.channel.ownerUserID == acc.getUserID()) {
try {
var sub = await APIClient.subscribeToChannelbyID(acc, widget.channel.channelID);
widget.onChannelListReloadTrigger.call();
widget.onSubscriptionChanged(widget.channel.channelID, sub);
if (sub.confirmed) {
Toaster.success("Success", 'Subscribed to channel');
} else {
Toaster.success("Success", 'Requested widget.subscription to channel');
}
} catch (exc, trace) {
Toaster.error("Error", 'Failed to subscribe to channel');
ApplicationLog.error('Failed to subscribe to channel: ' + exc.toString(), trace: trace);
}
}
}
void _unsubscribe(Subscription sub) async {
final acc = AppAuth();
if (acc.isAuth() && widget.channel.ownerUserID == acc.getUserID() && widget.subscription != null) {
try {
await APIClient.deleteSubscription(acc, widget.channel.channelID, widget.subscription!.subscriptionID);
widget.onChannelListReloadTrigger.call();
widget.onSubscriptionChanged.call(widget.channel.channelID, null);
Toaster.success("Success", 'Unsubscribed from channel');
} catch (exc, trace) {
Toaster.error("Error", 'Failed to unsubscribe from channel');
ApplicationLog.error('Failed to unsubscribe from channel: ' + exc.toString(), trace: trace);
}
}
}
}

View File

@@ -0,0 +1,117 @@
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import 'package:simplecloudnotifier/components/layout/scaffold.dart';
import 'package:simplecloudnotifier/models/scan_result.dart';
import 'package:simplecloudnotifier/utils/ui.dart';
class ChannelScannerPage extends StatefulWidget {
const ChannelScannerPage({super.key});
@override
State<ChannelScannerPage> createState() => _ChannelScannerPageState();
}
class _ChannelScannerPageState extends State<ChannelScannerPage> {
final MobileScannerController _controller = MobileScannerController(
formats: const [BarcodeFormat.qrCode],
);
ScanResult? scanResult = null;
@override
void initState() {
super.initState();
}
@override
Widget build(BuildContext context) {
return SCNScaffold(
title: "Scanner",
showSearch: false,
showShare: false,
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 16),
child: Column(
children: [
SizedBox(height: 16),
Center(
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(UI.DefaultBorderRadius),
),
clipBehavior: Clip.hardEdge,
child: SizedBox(
height: 300,
width: 300,
child: MobileScanner(
fit: BoxFit.cover,
controller: _controller,
onDetect: _handleBarcode,
),
),
),
),
SizedBox(height: 16),
_buildScanResult(context),
],
),
),
),
);
}
void _handleBarcode(BarcodeCapture barcodes) {
setState(() {
if (barcodes.barcodes.isEmpty) {
scanResult = null;
} else {
print('parsed: ${barcodes.barcodes[0].rawValue}');
scanResult = ScanResult.parse(barcodes.barcodes[0].rawValue ?? '');
}
});
}
Widget _buildScanResult(BuildContext context) {
if (scanResult == null) {
return UI.box(
padding: EdgeInsets.fromLTRB(16, 2, 4, 2), //TODO
context: context,
child: Center(
child: Icon(FontAwesomeIcons.solidEmptySet, size: 64, color: Theme.of(context).colorScheme.onPrimaryContainer.withAlpha(128)),
),
);
}
if (scanResult! is ScanResultMessageSend) {
return UI.box(
padding: EdgeInsets.fromLTRB(16, 2, 4, 2),
context: context,
child: Text("TODO -- ScanResultMessageSend"), //TODO
);
}
if (scanResult! is ScanResultChannel) {
return UI.box(
padding: EdgeInsets.fromLTRB(16, 2, 4, 2),
context: context,
child: Text("TODO -- ScanResultChannel"), //TODO
);
}
if (scanResult! is ScanResultChannelSubscribe) {
return UI.box(
padding: EdgeInsets.fromLTRB(16, 2, 4, 2),
context: context,
child: Text("TODO -- ScanResultChannelSubscribe"), //TODO
);
}
return UI.box(
padding: EdgeInsets.fromLTRB(16, 2, 4, 2),
context: context,
child: Text("TODO -- ERROR"), //TODO
);
}
}

View File

@@ -0,0 +1,105 @@
import 'package:flutter/material.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:simplecloudnotifier/api/api_client.dart';
import 'package:simplecloudnotifier/components/layout/scaffold.dart';
import 'package:simplecloudnotifier/models/channel.dart';
import 'package:simplecloudnotifier/models/scn_message.dart';
import 'package:simplecloudnotifier/pages/message_list/message_list_item.dart';
import 'package:simplecloudnotifier/pages/message_view/message_view.dart';
import 'package:simplecloudnotifier/settings/app_settings.dart';
import 'package:simplecloudnotifier/state/app_auth.dart';
import 'package:simplecloudnotifier/state/application_log.dart';
import 'package:simplecloudnotifier/state/scn_data_cache.dart';
import 'package:simplecloudnotifier/utils/navi.dart';
import 'package:provider/provider.dart';
class ChannelMessageViewPage extends StatefulWidget {
const ChannelMessageViewPage({
required this.channel,
super.key,
});
final Channel channel;
@override
State<ChannelMessageViewPage> createState() => _ChannelMessageViewPageState();
}
class _ChannelMessageViewPageState extends State<ChannelMessageViewPage> {
PagingController<String, SCNMessage> _pagingController = PagingController.fromValue(PagingState(nextPageKey: null, itemList: [], error: null), firstPageKey: '@start');
@override
void initState() {
super.initState();
_pagingController.addPageRequestListener(_fetchPage);
_pagingController.refresh();
}
@override
void dispose() {
_pagingController.dispose();
super.dispose();
}
Future<void> _fetchPage(String thisPageToken) async {
final acc = Provider.of<AppAuth>(context, listen: false);
final cfg = Provider.of<AppSettings>(context, listen: false);
ApplicationLog.debug('Start ChannelMessageViewPage::_pagingController::_fetchPage [ ${thisPageToken} ]');
if (!acc.isAuth()) {
_pagingController.error = 'Not logged in';
return;
}
try {
final (npt, newItems) = await APIClient.getChannelMessageList(acc, this.widget.channel.channelID, thisPageToken, pageSize: cfg.messagePageSize);
SCNDataCache().addToMessageCache(newItems); // no await
if (npt == '@end') {
_pagingController.appendLastPage(newItems);
} else {
_pagingController.appendPage(newItems, npt);
}
} catch (exc, trace) {
_pagingController.error = exc.toString();
ApplicationLog.error('Failed to list channel-messages: ' + exc.toString(), trace: trace);
}
}
@override
Widget build(BuildContext context) {
return SCNScaffold(
title: this.widget.channel.displayName,
showSearch: false,
showShare: false,
child: _buildMessageList(context),
);
}
Widget _buildMessageList(BuildContext context) {
return Padding(
padding: EdgeInsets.fromLTRB(8, 4, 8, 4),
child: RefreshIndicator(
onRefresh: () => Future.sync(
() => _pagingController.refresh(),
),
child: PagedListView<String, SCNMessage>(
pagingController: _pagingController,
builderDelegate: PagedChildBuilderDelegate<SCNMessage>(
itemBuilder: (context, item, index) => MessageListItem(
message: item,
allChannels: {this.widget.channel.channelID: this.widget.channel},
onPressed: () {
Navi.push(context, () => MessageViewPage(messageID: item.messageID, preloadedData: (item,)));
},
),
),
),
),
);
}
}

View File

@@ -0,0 +1,671 @@
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:share_plus/share_plus.dart';
import 'package:simplecloudnotifier/api/api_client.dart';
import 'package:simplecloudnotifier/components/layout/scaffold.dart';
import 'package:simplecloudnotifier/models/channel.dart';
import 'package:simplecloudnotifier/models/scan_result.dart';
import 'package:simplecloudnotifier/models/subscription.dart';
import 'package:simplecloudnotifier/models/user.dart';
import 'package:simplecloudnotifier/pages/channel_message_view/channel_message_view.dart';
import 'package:simplecloudnotifier/state/app_auth.dart';
import 'package:simplecloudnotifier/state/app_bar_state.dart';
import 'package:simplecloudnotifier/state/application_log.dart';
import 'package:simplecloudnotifier/types/immediate_future.dart';
import 'package:simplecloudnotifier/utils/navi.dart';
import 'package:simplecloudnotifier/utils/toaster.dart';
import 'package:simplecloudnotifier/utils/ui.dart';
import 'package:provider/provider.dart';
class ChannelViewPage extends StatefulWidget {
const ChannelViewPage({
required this.channelID,
required this.preloadedData,
required this.needsReload,
super.key,
});
final String channelID;
final (Channel, Subscription?)? preloadedData;
final void Function()? needsReload;
@override
State<ChannelViewPage> createState() => _ChannelViewPageState();
}
enum EditState { none, editing, saving }
enum ChannelViewPageInitState { loading, okay, error }
class _ChannelViewPageState extends State<ChannelViewPage> {
late ImmediateFuture<String?> _futureSubscribeKey;
late ImmediateFuture<List<(Subscription, UserPreview?)>> _futureSubscriptions;
late ImmediateFuture<UserPreview> _futureOwner;
final TextEditingController _ctrlDisplayName = TextEditingController();
final TextEditingController _ctrlDescriptionName = TextEditingController();
int _loadingIndeterminateCounter = 0;
EditState _editDisplayName = EditState.none;
String? _displayNameOverride = null;
EditState _editDescriptionName = EditState.none;
String? _descriptionNameOverride = null;
ChannelPreview? channelPreview;
Channel? channel;
Subscription? subscription;
ChannelViewPageInitState loadingState = ChannelViewPageInitState.loading;
String errorMessage = '';
@override
void initState() {
_initStateAsync(true);
super.initState();
}
Future<void> _initStateAsync(bool usePreload) async {
final userAcc = Provider.of<AppAuth>(context, listen: false);
if (widget.preloadedData != null && usePreload) {
channelPreview = widget.preloadedData!.$1.toPreview();
channel = widget.preloadedData!.$1;
subscription = widget.preloadedData!.$2;
} else {
try {
var p = await APIClient.getChannelPreview(userAcc, widget.channelID);
channelPreview = p;
if (p.ownerUserID == userAcc.userID) {
var r = await APIClient.getChannel(userAcc, widget.channelID);
channel = r.channel;
subscription = r.subscription;
} else {
channel = null;
subscription = null; //TODO get own subscription on this channel, even though its foreign channel
}
} catch (exc, trace) {
ApplicationLog.error('Failed to load data: ' + exc.toString(), trace: trace);
Toaster.error("Error", 'Failed to load data');
this.errorMessage = 'Failed to load data: ' + exc.toString();
this.loadingState = ChannelViewPageInitState.error;
return;
}
}
this.loadingState = ChannelViewPageInitState.okay;
assert(channelPreview != null);
if (this.channelPreview!.ownerUserID == userAcc.userID) {
if (this.channel != null && this.channel!.subscribeKey != null) {
_futureSubscribeKey = ImmediateFuture<String?>.ofValue(this.channel!.subscribeKey);
} else {
_futureSubscribeKey = ImmediateFuture<String?>.ofFuture(_getSubscribeKey(userAcc));
}
_futureSubscriptions = ImmediateFuture<List<(Subscription, UserPreview?)>>.ofFuture(_listSubscriptions(userAcc));
} else {
_futureSubscribeKey = ImmediateFuture<String?>.ofValue(null);
_futureSubscriptions = ImmediateFuture<List<(Subscription, UserPreview?)>>.ofValue([]);
}
if (this.channelPreview!.ownerUserID == userAcc.userID) {
var cacheUser = userAcc.getUserOrNull();
if (cacheUser != null) {
_futureOwner = ImmediateFuture<UserPreview>.ofValue(cacheUser.toPreview());
} else {
_futureOwner = ImmediateFuture<UserPreview>.ofFuture(_getOwner(userAcc));
}
} else {
_futureOwner = ImmediateFuture<UserPreview>.ofFuture(APIClient.getUserPreview(userAcc, this.channelPreview!.ownerUserID));
}
}
@override
void dispose() {
_ctrlDisplayName.dispose();
_ctrlDescriptionName.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final userAcc = Provider.of<AppAuth>(context, listen: false);
Widget child;
if (loadingState == ChannelViewPageInitState.loading) {
child = Center(child: CircularProgressIndicator());
} else if (loadingState == ChannelViewPageInitState.error) {
child = Center(child: Text('Error: ' + errorMessage)); //TODO better error
} else if (loadingState == ChannelViewPageInitState.okay && channelPreview!.ownerUserID == userAcc.userID) {
child = _buildOwnedChannelView(context, this.channel!);
} else {
child = _buildForeignChannelView(context, this.channelPreview!);
}
return SCNScaffold(
title: 'Channel',
showSearch: false,
showShare: false,
child: child,
);
}
Widget _buildOwnedChannelView(BuildContext context, Channel channel) {
final isSubscribed = (subscription != null && subscription!.confirmed);
return SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildQRCode(context),
SizedBox(height: 8),
UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidIdCardClip,
title: 'ChannelID',
values: [channel.channelID],
),
UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidInputNumeric,
title: 'InternalName',
values: [channel.internalName],
),
_buildDisplayNameCard(context, true),
_buildDescriptionNameCard(context, true),
UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidDiagramSubtask,
title: 'Subscription (own)',
values: [_formatSubscriptionStatus(this.subscription)],
iconActions: isSubscribed ? [(FontAwesomeIcons.solidSquareXmark, _unsubscribe)] : [(FontAwesomeIcons.solidSquareRss, _subscribe)],
),
_buildForeignSubscriptions(context),
_buildOwnerCard(context, true),
UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidEnvelope,
title: 'Messages',
values: [channel.messagesSent.toString()],
mainAction: () {
Navi.push(context, () => ChannelMessageViewPage(channel: channel));
},
),
],
),
),
);
}
Widget _buildForeignChannelView(BuildContext context, ChannelPreview channel) {
final isSubscribed = (subscription != null && subscription!.confirmed);
return SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(height: 8),
UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidIdCardClip,
title: 'ChannelID',
values: [channel.channelID],
),
UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidInputNumeric,
title: 'InternalName',
values: [channel.internalName],
),
_buildDisplayNameCard(context, false),
_buildDescriptionNameCard(context, false),
UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidDiagramSubtask,
title: 'Subscription (foreign)',
values: [_formatSubscriptionStatus(subscription)],
iconActions: isSubscribed ? [(FontAwesomeIcons.solidSquareXmark, _unsubscribe)] : [(FontAwesomeIcons.solidSquareRss, _subscribe)],
),
_buildForeignSubscriptions(context),
_buildOwnerCard(context, false),
],
),
),
);
}
Widget _buildForeignSubscriptions(BuildContext context) {
return FutureBuilder(
future: _futureSubscriptions.future,
builder: (context, snapshot) {
if (snapshot.hasData) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final (sub, user) in snapshot.data!.where((v) => v.$1.subscriptionID != subscription?.subscriptionID))
UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidDiagramSuccessor,
title: 'Subscription (' + (user?.username ?? user?.userID ?? 'other') + ')',
values: [_formatSubscriptionStatus(sub)],
iconActions: _getForeignSubActions(sub),
),
],
);
} else {
return SizedBox();
}
},
);
}
Widget _buildOwnerCard(BuildContext context, bool isOwned) {
return FutureBuilder(
future: _futureOwner.future,
builder: (context, snapshot) {
if (snapshot.hasData) {
return UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidUser,
title: 'Owner',
values: [channelPreview!.ownerUserID + (isOwned ? ' (you)' : ''), if (snapshot.data?.username != null) snapshot.data!.username!],
);
} else {
return UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidUser,
title: 'Owner',
values: [channelPreview!.ownerUserID + (isOwned ? ' (you)' : '')],
);
}
},
);
}
Widget _buildQRCode(BuildContext context) {
return FutureBuilder(
future: _futureSubscribeKey.future,
builder: (context, snapshot) {
if (snapshot.hasData) {
final text = (snapshot.data == null) ? ScanResult.createChannelQR(channel!) : ScanResult.createChannelSubscribeQR(channel!, snapshot.data!);
return GestureDetector(
onTap: () {
Share.share(text, subject: _displayNameOverride ?? channel!.displayName);
},
child: Center(
child: QrImageView(
data: text,
version: QrVersions.auto,
size: 265.0,
eyeStyle: QrEyeStyle(
eyeShape: QrEyeShape.square,
color: Theme.of(context).textTheme.bodyLarge?.color,
),
dataModuleStyle: QrDataModuleStyle(
dataModuleShape: QrDataModuleShape.square,
color: Theme.of(context).textTheme.bodyLarge?.color,
),
),
),
);
} else {
return const SizedBox(
width: 300.0,
height: 300.0,
child: Center(child: CircularProgressIndicator()),
);
}
},
);
}
Widget _buildDisplayNameCard(BuildContext context, bool isOwned) {
if (_editDisplayName == EditState.editing) {
return Padding(
padding: EdgeInsets.symmetric(vertical: 4, horizontal: 0),
child: UI.box(
context: context,
padding: EdgeInsets.fromLTRB(16, 2, 4, 2),
child: Row(
children: [
Container(child: Center(child: FaIcon(FontAwesomeIcons.solidInputText, size: 18)), height: 43),
SizedBox(width: 16),
Expanded(
child: TextField(
autofocus: true,
controller: _ctrlDisplayName,
decoration: new InputDecoration.collapsed(hintText: 'DisplayName'),
),
),
SizedBox(width: 12),
SizedBox(width: 4),
IconButton(icon: FaIcon(FontAwesomeIcons.solidFloppyDisk), onPressed: _saveDisplayName),
],
),
),
);
} else if (_editDisplayName == EditState.none) {
return UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidInputText,
title: 'DisplayName',
values: [_displayNameOverride ?? channelPreview!.displayName],
iconActions: isOwned ? [(FontAwesomeIcons.penToSquare, _showEditDisplayName)] : [],
);
} else if (_editDisplayName == EditState.saving) {
return Padding(
padding: EdgeInsets.symmetric(vertical: 4, horizontal: 0),
child: UI.box(
context: context,
padding: EdgeInsets.fromLTRB(16, 2, 4, 2),
child: Row(
children: [
Container(child: Center(child: FaIcon(FontAwesomeIcons.solidInputText, size: 18)), height: 43),
SizedBox(width: 16),
Expanded(child: SizedBox()),
SizedBox(width: 12),
SizedBox(width: 4),
Padding(padding: const EdgeInsets.all(8.0), child: SizedBox(width: 18, height: 18, child: CircularProgressIndicator())),
],
),
),
);
} else {
throw 'Invalid EditDisplayNameState: $_editDisplayName';
}
}
Widget _buildDescriptionNameCard(BuildContext context, bool isOwned) {
if (_editDescriptionName == EditState.editing) {
return Padding(
padding: EdgeInsets.symmetric(vertical: 4, horizontal: 0),
child: UI.box(
context: context,
padding: EdgeInsets.fromLTRB(16, 2, 4, 2),
child: Row(
children: [
Container(child: Center(child: FaIcon(FontAwesomeIcons.solidInputPipe, size: 18)), height: 43),
SizedBox(width: 16),
Expanded(
child: TextField(
autofocus: true,
controller: _ctrlDescriptionName,
decoration: new InputDecoration.collapsed(hintText: 'Description'),
),
),
SizedBox(width: 12),
SizedBox(width: 4),
IconButton(icon: FaIcon(FontAwesomeIcons.solidFloppyDisk), onPressed: _saveDescriptionName),
],
),
),
);
} else if (_editDescriptionName == EditState.none) {
return UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidInputPipe,
title: 'Description',
values: [_descriptionNameOverride ?? channelPreview?.descriptionName ?? ''],
iconActions: isOwned ? [(FontAwesomeIcons.penToSquare, _showEditDescriptionName)] : [],
);
} else if (_editDescriptionName == EditState.saving) {
return Padding(
padding: EdgeInsets.symmetric(vertical: 4, horizontal: 0),
child: UI.box(
context: context,
padding: EdgeInsets.fromLTRB(16, 2, 4, 2),
child: Row(
children: [
Container(child: Center(child: FaIcon(FontAwesomeIcons.solidInputPipe, size: 18)), height: 43),
SizedBox(width: 16),
Expanded(child: SizedBox()),
SizedBox(width: 12),
SizedBox(width: 4),
Padding(padding: const EdgeInsets.all(8.0), child: SizedBox(width: 18, height: 18, child: CircularProgressIndicator())),
],
),
),
);
} else {
throw 'Invalid EditDescriptionNameState: $_editDescriptionName';
}
}
void _showEditDisplayName() {
setState(() {
_ctrlDisplayName.text = _displayNameOverride ?? channelPreview?.displayName ?? '';
_editDisplayName = EditState.editing;
if (_editDescriptionName == EditState.editing) _editDescriptionName = EditState.none;
});
}
void _saveDisplayName() async {
final userAcc = Provider.of<AppAuth>(context, listen: false);
final newName = _ctrlDisplayName.text;
try {
setState(() {
_editDisplayName = EditState.saving;
});
final newChannel = await APIClient.updateChannel(userAcc, widget.channelID, displayName: newName);
setState(() {
_editDisplayName = EditState.none;
_displayNameOverride = newChannel.channel.displayName;
});
widget.needsReload?.call();
} catch (exc, trace) {
ApplicationLog.error('Failed to save DisplayName: ' + exc.toString(), trace: trace);
Toaster.error("Error", 'Failed to save DisplayName');
}
}
void _showEditDescriptionName() {
setState(() {
_ctrlDescriptionName.text = _descriptionNameOverride ?? channelPreview?.descriptionName ?? '';
_editDescriptionName = EditState.editing;
if (_editDisplayName == EditState.editing) _editDisplayName = EditState.none;
});
}
void _saveDescriptionName() async {
final userAcc = Provider.of<AppAuth>(context, listen: false);
final newName = _ctrlDescriptionName.text;
try {
setState(() {
_editDescriptionName = EditState.saving;
});
final newChannel = await APIClient.updateChannel(userAcc, widget.channelID, descriptionName: newName);
setState(() {
_editDescriptionName = EditState.none;
_descriptionNameOverride = newChannel.channel.descriptionName ?? '';
});
widget.needsReload?.call();
} catch (exc, trace) {
ApplicationLog.error('Failed to save DescriptionName: ' + exc.toString(), trace: trace);
Toaster.error("Error", 'Failed to save DescriptionName');
}
}
void _subscribe() async {
final acc = AppAuth();
try {
var sub = await APIClient.subscribeToChannelbyID(acc, widget.channelID);
widget.needsReload?.call();
await _initStateAsync(false);
if (sub.confirmed) {
Toaster.success("Success", 'Subscribed to channel');
} else {
Toaster.success("Success", 'Requested subscription to channel');
}
} catch (exc, trace) {
Toaster.error("Error", 'Failed to subscribe to channel');
ApplicationLog.error('Failed to subscribe to channel: ' + exc.toString(), trace: trace);
}
}
void _unsubscribe() async {
final acc = AppAuth();
if (subscription == null) return;
try {
await APIClient.deleteSubscription(acc, widget.channelID, subscription!.subscriptionID);
widget.needsReload?.call();
await _initStateAsync(false);
Toaster.success("Success", 'Unsubscribed from channel');
} catch (exc, trace) {
Toaster.error("Error", 'Failed to unsubscribe from channel');
ApplicationLog.error('Failed to unsubscribe from channel: ' + exc.toString(), trace: trace);
}
}
void _cancelForeignSubscription(Subscription sub) async {
final acc = AppAuth();
try {
await APIClient.unconfirmSubscription(acc, widget.channelID, sub.subscriptionID);
widget.needsReload?.call();
await _initStateAsync(false);
Toaster.success("Success", 'Subscription succesfully revoked');
} catch (exc, trace) {
Toaster.error("Error", 'Failed to revoke subscription');
ApplicationLog.error('Failed to revoke subscription: ' + exc.toString(), trace: trace);
}
}
void _confirmForeignSubscription(Subscription sub) async {
final acc = AppAuth();
try {
await APIClient.confirmSubscription(acc, widget.channelID, sub.subscriptionID);
widget.needsReload?.call();
await _initStateAsync(false);
Toaster.success("Success", 'Subscription succesfully confirmed');
} catch (exc, trace) {
Toaster.error("Error", 'Failed to confirm subscription');
ApplicationLog.error('Failed to confirm subscription: ' + exc.toString(), trace: trace);
}
}
void _denyForeignSubscription(Subscription sub) async {
final acc = AppAuth();
try {
await APIClient.deleteSubscription(acc, widget.channelID, sub.subscriptionID);
widget.needsReload?.call();
await _initStateAsync(false);
Toaster.success("Success", 'Subscription request succesfully denied');
} catch (exc, trace) {
Toaster.error("Error", 'Failed to deny subscription');
ApplicationLog.error('Failed to deny subscription: ' + exc.toString(), trace: trace);
}
}
String _formatSubscriptionStatus(Subscription? subscription) {
if (subscription == null) {
return 'Not Subscribed';
} else if (subscription.confirmed) {
return 'Subscribed';
} else {
return 'Requested';
}
}
Future<String?> _getSubscribeKey(AppAuth auth) async {
try {
await Future.delayed(const Duration(seconds: 0), () {}); // this is annoyingly important - otherwise we call setLoadingIndeterminate directly in initStat() and get an exception....
_incLoadingIndeterminateCounter(1);
var channel = await APIClient.getChannel(auth, widget.channelID);
//await Future.delayed(const Duration(seconds: 10), () {});
return channel.channel.subscribeKey;
} finally {
_incLoadingIndeterminateCounter(-1);
}
}
Future<List<(Subscription, UserPreview?)>> _listSubscriptions(AppAuth auth) async {
try {
await Future.delayed(const Duration(seconds: 0), () {}); // this is annoyingly important - otherwise we call setLoadingIndeterminate directly in initStat() and get an exception....
_incLoadingIndeterminateCounter(1);
var subs = await APIClient.getChannelSubscriptions(auth, widget.channelID);
var userMap = {for (var v in (await Future.wait(subs.map((e) => e.subscriberUserID).toSet().map((e) => APIClient.getUserPreview(auth, e)).toList()))) v.userID: v};
//await Future.delayed(const Duration(seconds: 10), () {});
return subs.map((e) => (e, userMap[e.subscriberUserID] ?? null)).toList();
} finally {
_incLoadingIndeterminateCounter(-1);
}
}
Future<UserPreview> _getOwner(AppAuth auth) async {
try {
await Future.delayed(const Duration(seconds: 0), () {}); // this is annoyingly important - otherwise we call setLoadingIndeterminate directly in initStat() and get an exception....
_incLoadingIndeterminateCounter(1);
final owner = APIClient.getUserPreview(auth, channelPreview!.ownerUserID);
//await Future.delayed(const Duration(seconds: 10), () {});
return owner;
} finally {
_incLoadingIndeterminateCounter(-1);
}
}
List<(IconData, void Function())> _getForeignSubActions(Subscription sub) {
if (sub.confirmed) {
return [(FontAwesomeIcons.solidSquareXmark, () => _cancelForeignSubscription(sub))];
} else {
return [
(FontAwesomeIcons.solidSquareCheck, () => _confirmForeignSubscription(sub)),
(FontAwesomeIcons.solidSquareXmark, () => _denyForeignSubscription(sub)),
];
}
}
void _incLoadingIndeterminateCounter(int delta) {
setState(() {
_loadingIndeterminateCounter += delta;
AppBarState().setLoadingIndeterminate(_loadingIndeterminateCounter > 0);
});
}
}

View File

@@ -1,4 +1,6 @@
import 'package:flutter/material.dart';
import 'package:simplecloudnotifier/state/application_log.dart';
import 'package:simplecloudnotifier/utils/notifier.dart';
import 'package:simplecloudnotifier/utils/toaster.dart';
import 'package:simplecloudnotifier/utils/ui.dart';
@@ -52,6 +54,12 @@ class _DebugActionsPageState extends State<DebugActionsPage> {
onPressed: _sendTokenToServer,
text: 'Send FCM Token to Server',
),
SizedBox(height: 20),
UI.button(
big: false,
onPressed: () => Notifier.showLocalNotification('', 'TEST_CHANNEL', "Test Channel", "Channel for testing", "Hello World", "Local Notification test", null),
text: 'Show local notification',
),
],
),
),

View File

@@ -55,11 +55,11 @@ class _DebugColorsPageState extends State<DebugColorsPage> {
buildCol("colorScheme.surface", Theme.of(context).colorScheme.surface),
buildCol("colorScheme.onSurface", Theme.of(context).colorScheme.onSurface),
buildCol("colorScheme.surfaceTint", Theme.of(context).colorScheme.surfaceTint),
buildCol("colorScheme.surfaceVariant", Theme.of(context).colorScheme.surfaceVariant),
buildCol("colorScheme.surfaceVariant", Theme.of(context).colorScheme.surfaceContainerHighest),
buildCol("colorScheme.inverseSurface", Theme.of(context).colorScheme.inverseSurface),
buildCol("colorScheme.onInverseSurface", Theme.of(context).colorScheme.onInverseSurface),
buildCol("colorScheme.background", Theme.of(context).colorScheme.background),
buildCol("colorScheme.onBackground", Theme.of(context).colorScheme.onBackground),
buildCol("colorScheme.background", Theme.of(context).colorScheme.surface),
buildCol("colorScheme.onBackground", Theme.of(context).colorScheme.onSurface),
buildCol("colorScheme.error", Theme.of(context).colorScheme.error),
buildCol("colorScheme.onError", Theme.of(context).colorScheme.onError),
buildCol("colorScheme.errorContainer", Theme.of(context).colorScheme.errorContainer),
@@ -98,7 +98,7 @@ class _DebugColorsPageState extends State<DebugColorsPage> {
buildCol("badgeTheme.backgroundColor", Theme.of(context).badgeTheme.backgroundColor),
buildCol("bannerTheme.backgroundColor", Theme.of(context).bannerTheme.backgroundColor),
buildCol("bottomAppBarTheme.color", Theme.of(context).bottomAppBarTheme.color),
buildCol("buttonTheme.colorScheme.background", Theme.of(context).buttonTheme.colorScheme?.background),
buildCol("buttonTheme.colorScheme.background", Theme.of(context).buttonTheme.colorScheme?.surface),
buildCol("buttonTheme.colorScheme.primary", Theme.of(context).buttonTheme.colorScheme?.primary),
buildCol("buttonTheme.colorScheme.secondary", Theme.of(context).buttonTheme.colorScheme?.secondary),
buildCol("cardTheme.color", Theme.of(context).cardTheme.color),

View File

@@ -30,7 +30,6 @@ class _DebugMainPageState extends State<DebugMainPage> {
return SCNScaffold(
title: 'Debug',
showSearch: false,
showDebug: false,
child: Column(
children: [
Padding(
@@ -61,7 +60,7 @@ class _DebugMainPageState extends State<DebugMainPage> {
ButtonSegment<DebugMainPageSubPage>(value: DebugMainPageSubPage.logs, icon: Icon(FontAwesomeIcons.solidFileLines, size: 14)),
],
style: ButtonStyle(
padding: MaterialStateProperty.all<EdgeInsets>(EdgeInsets.fromLTRB(0, 0, 0, 0)),
padding: WidgetStateProperty.all<EdgeInsets>(EdgeInsets.fromLTRB(0, 0, 0, 0)),
visualDensity: VisualDensity(horizontal: -3, vertical: -3),
),
selected: <DebugMainPageSubPage>{_subPage},

View File

@@ -1,15 +1,20 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:simplecloudnotifier/models/channel.dart';
import 'package:simplecloudnotifier/models/message.dart';
import 'package:simplecloudnotifier/models/scn_message.dart';
import 'package:simplecloudnotifier/pages/debug/debug_persistence_failurelogs.dart';
import 'package:simplecloudnotifier/pages/debug/debug_persistence_hive.dart';
import 'package:simplecloudnotifier/pages/debug/debug_persistence_sharedprefs.dart';
import 'package:simplecloudnotifier/state/application_log.dart';
import 'package:simplecloudnotifier/state/fb_message.dart';
import 'package:simplecloudnotifier/state/globals.dart';
import 'package:simplecloudnotifier/state/interfaces.dart';
import 'package:simplecloudnotifier/state/request_log.dart';
import 'package:simplecloudnotifier/utils/navi.dart';
import 'package:path/path.dart' as path;
class DebugPersistencePage extends StatefulWidget {
@override
@@ -36,9 +41,10 @@ class _DebugPersistencePageState extends State<DebugPersistencePage> {
_buildSharedPrefCard(context),
_buildHiveCard(context, () => Hive.box<SCNRequest>('scn-requests'), 'scn-requests'),
_buildHiveCard(context, () => Hive.box<SCNLog>('scn-logs'), 'scn-logs'),
_buildHiveCard(context, () => Hive.box<Message>('scn-message-cache'), 'scn-message-cache'),
_buildHiveCard(context, () => Hive.box<SCNMessage>('scn-message-cache'), 'scn-message-cache'),
_buildHiveCard(context, () => Hive.box<Channel>('scn-channel-cache'), 'scn-channel-cache'),
_buildHiveCard(context, () => Hive.box<FBMessage>('scn-fb-messages'), 'scn-fb-messages'),
_buildFailureLogCard(context, Globals().rawFailureLogsDir),
],
),
);
@@ -71,7 +77,7 @@ class _DebugPersistencePageState extends State<DebugPersistencePage> {
padding: const EdgeInsets.all(8.0),
child: GestureDetector(
onTap: () {
Navi.push(context, () => DebugHiveBoxPage(boxName: boxname, box: Hive.box<FBMessage>(boxname)));
Navi.push(context, () => DebugHiveBoxPage(boxName: boxname, box: boxFunc()));
},
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
@@ -85,4 +91,25 @@ class _DebugPersistencePageState extends State<DebugPersistencePage> {
),
);
}
Widget _buildFailureLogCard(BuildContext context, Directory dir) {
return Card.outlined(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: GestureDetector(
onTap: () {
Navi.push(context, () => DebugFailureLogsPage(dir: dir.path));
},
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
SizedBox(width: 30, child: Text('')),
Expanded(child: Text('Failure [/${path.basename(dir.path)}/]', style: TextStyle(fontWeight: FontWeight.bold), textAlign: TextAlign.center)),
SizedBox(width: 40, child: Text("${dir.listSync().length}", textAlign: TextAlign.end)),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,63 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:simplecloudnotifier/components/layout/scaffold.dart';
import 'package:simplecloudnotifier/types/immediate_future.dart';
class DebugFailureLogFilePage extends StatefulWidget {
final String path;
DebugFailureLogFilePage({required this.path}) {}
@override
State<DebugFailureLogFilePage> createState() => _DebugFailureLogFilePageState();
}
class _DebugFailureLogFilePageState extends State<DebugFailureLogFilePage> {
ImmediateFuture<String>? _futureContent;
@override
void initState() {
super.initState();
_futureContent = ImmediateFuture.ofFuture(new File(this.widget.path).readAsString());
}
@override
Widget build(BuildContext context) {
return SCNScaffold(
title: 'FailureLog',
showSearch: false,
child: () {
if (_futureContent == null) {
return Center(child: CircularProgressIndicator());
}
return FutureBuilder(
future: _futureContent!.future,
builder: ((context, snapshot) {
if (_futureContent?.value != null) {
return _buildContent(context, _futureContent!.value!);
} else if (snapshot.connectionState == ConnectionState.done && snapshot.hasError) {
return Text('Error: ${snapshot.error}'); //TODO better error display
} else if (snapshot.connectionState == ConnectionState.done) {
return _buildContent(context, snapshot.data!);
} else {
return Center(child: CircularProgressIndicator());
}
}),
);
}(),
);
}
Widget _buildContent(BuildContext context, String value) {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(value, style: TextStyle(fontFamily: "monospace")),
),
);
}
}

View File

@@ -0,0 +1,86 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:simplecloudnotifier/components/layout/scaffold.dart';
import 'package:simplecloudnotifier/pages/debug/debug_persistence_failurelogfile.dart';
import 'package:simplecloudnotifier/state/application_log.dart';
import 'package:simplecloudnotifier/state/globals.dart';
import 'package:simplecloudnotifier/utils/navi.dart';
import 'package:path/path.dart' as path;
import 'package:simplecloudnotifier/utils/toaster.dart';
class DebugFailureLogsPage extends StatefulWidget {
final String dir;
DebugFailureLogsPage({required this.dir});
@override
State<DebugFailureLogsPage> createState() => _DebugFailureLogsPageState();
}
class _DebugFailureLogsPageState extends State<DebugFailureLogsPage> {
List<String> files = [];
_DebugFailureLogsPageState() {
files = _listFilesInRawLogFolder();
}
@override
Widget build(BuildContext context) {
return SCNScaffold(
title: 'F-Logs',
showSearch: false,
child: ListView.separated(
itemCount: files.length,
itemBuilder: (context, listIndex) {
return GestureDetector(
onTap: () {
Navi.push(context, () => DebugFailureLogFilePage(path: files[listIndex]));
},
child: Container(
padding: EdgeInsets.fromLTRB(8, 0, 8, 0),
child: Row(
children: [
Expanded(child: Text(path.basename(files[listIndex]), style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12))),
IconButton(
icon: const Icon(FontAwesomeIcons.trash),
tooltip: 'Delete',
iconSize: 16,
color: Colors.red,
onPressed: () => _deleteFile(context, files[listIndex]),
)
],
),
),
);
},
separatorBuilder: (context, index) => Divider(),
),
);
}
List<String> _listFilesInRawLogFolder() {
final fse = Globals().rawFailureLogsDir.listSync();
ApplicationLog.debug("Found ${fse.length} files in raw log folder '${Globals().rawFailureLogsDir.path}'");
var paths = fse.where((element) => element is File).map((e) => e.path).toList();
paths.sort((a, b) => -1 * a.compareTo(b));
return paths;
}
void _deleteFile(BuildContext context, String fil) {
final file = File(fil);
file.deleteSync();
setState(() {
files = _listFilesInRawLogFolder();
});
Toaster.info("Okay", "File deleted");
}
}

View File

@@ -16,7 +16,6 @@ class DebugHiveBoxPage extends StatelessWidget {
return SCNScaffold(
title: 'Hive: ' + boxName,
showSearch: false,
showDebug: false,
child: ListView.separated(
itemCount: box.length,
itemBuilder: (context, listIndex) {
@@ -24,8 +23,9 @@ class DebugHiveBoxPage extends StatelessWidget {
onTap: () {
Navi.push(context, () => DebugHiveEntryPage(value: box.getAt(listIndex)!));
},
child: ListTile(
title: Text(box.getAt(listIndex).toString(), style: TextStyle(fontWeight: FontWeight.bold)),
child: Container(
padding: EdgeInsets.fromLTRB(8, 4, 8, 4),
child: Text(box.getAt(listIndex).toString(), style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12)),
),
);
},

View File

@@ -13,11 +13,13 @@ class DebugHiveEntryPage extends StatelessWidget {
return SCNScaffold(
title: 'HiveEntry',
showSearch: false,
showDebug: false,
child: ListView.separated(
itemCount: fields.length,
itemBuilder: (context, listIndex) {
return ListTile(
dense: true,
contentPadding: EdgeInsets.fromLTRB(8, 0, 8, 0),
visualDensity: VisualDensity(horizontal: 0, vertical: -4),
title: Text(fields[listIndex].$1, style: TextStyle(fontWeight: FontWeight.bold)),
subtitle: Text(fields[listIndex].$2, style: TextStyle(fontFamily: "monospace")),
);

View File

@@ -13,7 +13,6 @@ class DebugSharedPrefPage extends StatelessWidget {
return SCNScaffold(
title: 'SharedPreferences',
showSearch: false,
showDebug: false,
child: ListView.separated(
itemCount: sharedPref.getKeys().length,
itemBuilder: (context, listIndex) {

View File

@@ -1,3 +1,5 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
@@ -6,17 +8,24 @@ import 'package:simplecloudnotifier/state/request_log.dart';
import 'package:simplecloudnotifier/utils/toaster.dart';
import 'package:simplecloudnotifier/utils/ui.dart';
class DebugRequestViewPage extends StatelessWidget {
class DebugRequestViewPage extends StatefulWidget {
final SCNRequest request;
DebugRequestViewPage({required this.request});
@override
State<DebugRequestViewPage> createState() => _DebugRequestViewPageState();
}
class _DebugRequestViewPageState extends State<DebugRequestViewPage> {
Set<String> _monospaceMode = new Set();
Set<String> _prettyJson = new Set();
@override
Widget build(BuildContext context) {
return SCNScaffold(
title: 'Request',
showSearch: false,
showDebug: false,
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(8.0),
@@ -24,22 +33,22 @@ class DebugRequestViewPage extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: MainAxisAlignment.start,
children: [
...buildRow(context, "Name", request.name),
...buildRow(context, "Timestamp (Start)", request.timestampStart.toString()),
...buildRow(context, "Timestamp (End)", request.timestampEnd.toString()),
...buildRow(context, "Duration", request.timestampEnd.difference(request.timestampStart).toString()),
...buildRow(context, "name", "Name", widget.request.name),
...buildRow(context, "timestampStart", "Timestamp (Start)", widget.request.timestampStart.toString()),
...buildRow(context, "timestampEnd", "Timestamp (End)", widget.request.timestampEnd.toString()),
...buildRow(context, "duration", "Duration", widget.request.timestampEnd.difference(widget.request.timestampStart).toString()),
Divider(),
...buildRow(context, "Method", request.method),
...buildRow(context, "URL", request.url),
if (request.requestHeaders.isNotEmpty) ...buildRow(context, "Request->Headers", request.requestHeaders.entries.map((v) => '${v.key} = ${v.value}').join('\n')),
if (request.requestBody != '') ...buildRow(context, "Request->Body", request.requestBody),
...buildRow(context, "method", "Method", widget.request.method),
...buildRow(context, "url", "URL", widget.request.url, mono: true),
if (widget.request.requestHeaders.isNotEmpty) ...buildRow(context, "request_headers", "Request->Headers", widget.request.requestHeaders.entries.map((v) => '${v.key} = ${v.value}').join('\n'), mono: true),
if (widget.request.requestBody != '') ...buildRow(context, "request_body", "Request->Body", widget.request.requestBody, mono: true, json: true),
Divider(),
if (request.responseStatusCode != 0) ...buildRow(context, "Response->Statuscode", request.responseStatusCode.toString()),
if (request.responseBody != '') ...buildRow(context, "Reponse->Body", request.responseBody),
if (request.responseHeaders.isNotEmpty) ...buildRow(context, "Reponse->Headers", request.responseHeaders.entries.map((v) => '${v.key} = ${v.value}').join('\n')),
if (widget.request.responseStatusCode != 0) ...buildRow(context, "response_statuscode", "Response->Statuscode", widget.request.responseStatusCode.toString()),
if (widget.request.responseBody != '') ...buildRow(context, "response_body", "Reponse->Body", widget.request.responseBody, mono: true, json: true),
if (widget.request.responseHeaders.isNotEmpty) ...buildRow(context, "response_headers", "Reponse->Headers", widget.request.responseHeaders.entries.map((v) => '${v.key} = ${v.value}').join('\n'), mono: true, json: true),
Divider(),
if (request.error != '') ...buildRow(context, "Error", request.error),
if (request.stackTrace != '') ...buildRow(context, "Stacktrace", request.stackTrace),
if (widget.request.error != '') ...buildRow(context, "error", "Error", widget.request.error, mono: true),
if (widget.request.stackTrace != '') ...buildRow(context, "trace", "Stacktrace", widget.request.stackTrace, mono: true),
],
),
),
@@ -47,7 +56,19 @@ class DebugRequestViewPage extends StatelessWidget {
);
}
List<Widget> buildRow(BuildContext context, String title, String value) {
List<Widget> buildRow(BuildContext context, String key, String title, String value, {bool? json, bool? mono}) {
var isMono = _monospaceMode.contains(key);
var isJson = _prettyJson.contains(key);
if (isJson) {
try {
var jsonValue = jsonDecode(value);
value = JsonEncoder.withIndent(' ').convert(jsonValue);
} catch (e) {
value = ('Error parsing JSON: $e');
}
}
return [
Padding(
padding: const EdgeInsets.symmetric(vertical: 0, horizontal: 8.0),
@@ -61,22 +82,50 @@ class DebugRequestViewPage extends StatelessWidget {
onPressed: () {
Clipboard.setData(new ClipboardData(text: title));
Toaster.info("Clipboard", 'Copied text to Clipboard');
print('================= [CLIPBOARD] =================\n${title}\n================= [/CLIPBOARD] =================');
},
icon: FontAwesomeIcons.copy,
),
if (mono == true)
UI.buttonIconOnly(
icon: isMono ? FontAwesomeIcons.lineColumns : FontAwesomeIcons.alignLeft,
onPressed: () {
setState(() {
_monospaceMode.contains(key) ? _monospaceMode.remove(key) : _monospaceMode.add(key);
});
},
),
if (json == true)
UI.buttonIconOnly(
icon: isJson ? FontAwesomeIcons.bracketsRound : FontAwesomeIcons.bracketsCurly,
onPressed: () {
setState(() {
_prettyJson.contains(key) ? _prettyJson.remove(key) : _prettyJson.add(key);
});
},
),
],
),
),
Card.filled(
shape: BeveledRectangleBorder(borderRadius: BorderRadius.circular(0)),
color: request.type == 'SUCCESS' ? null : Theme.of(context).colorScheme.errorContainer,
color: widget.request.type == 'SUCCESS' ? null : Theme.of(context).colorScheme.errorContainer,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 2.0, horizontal: 6.0),
child: (isMono || isJson)
? SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: SelectableText(
value,
minLines: 1,
maxLines: 10,
),
)
: SelectableText(
value,
minLines: 1,
maxLines: 10,
),
),
),
];

View File

@@ -0,0 +1,36 @@
import 'package:flutter/src/widgets/icon_data.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
enum MessageFilterChipletType {
search,
channel,
sender,
timeRange,
priority,
sendkey,
}
class MessageFilterChiplet {
final String label; // display value
final dynamic value; // search/api value
final MessageFilterChipletType type;
MessageFilterChiplet({required this.label, required this.value, required this.type});
IconData? icon() {
switch (type) {
case MessageFilterChipletType.search:
return FontAwesomeIcons.magnifyingGlass;
case MessageFilterChipletType.channel:
return FontAwesomeIcons.snake;
case MessageFilterChipletType.sender:
return FontAwesomeIcons.signature;
case MessageFilterChipletType.timeRange:
return FontAwesomeIcons.timer;
case MessageFilterChipletType.priority:
return FontAwesomeIcons.bolt;
case MessageFilterChipletType.sendkey:
return FontAwesomeIcons.gearCode;
}
}
}

View File

@@ -1,15 +1,18 @@
import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:provider/provider.dart';
import 'package:simplecloudnotifier/api/api_client.dart';
import 'package:simplecloudnotifier/models/channel.dart';
import 'package:simplecloudnotifier/models/message.dart';
import 'package:simplecloudnotifier/models/scn_message.dart';
import 'package:simplecloudnotifier/pages/message_list/message_filter_chiplet.dart';
import 'package:simplecloudnotifier/pages/message_view/message_view.dart';
import 'package:simplecloudnotifier/settings/app_settings.dart';
import 'package:simplecloudnotifier/state/app_bar_state.dart';
import 'package:simplecloudnotifier/state/app_events.dart';
import 'package:simplecloudnotifier/state/application_log.dart';
import 'package:simplecloudnotifier/state/app_auth.dart';
import 'package:simplecloudnotifier/pages/message_list/message_list_item.dart';
import 'package:simplecloudnotifier/state/scn_data_cache.dart';
import 'package:simplecloudnotifier/utils/navi.dart';
class MessageListPage extends StatefulWidget {
@@ -17,28 +20,28 @@ class MessageListPage extends StatefulWidget {
final bool isVisiblePage;
//TODO reload on switch to tab
//TODO reload on app to foreground
@override
State<MessageListPage> createState() => _MessageListPageState();
}
class _MessageListPageState extends State<MessageListPage> with RouteAware {
static const _pageSize = 128;
late final AppLifecycleListener _lifecyleListener;
PagingController<String, Message> _pagingController = PagingController.fromValue(PagingState(nextPageKey: null, itemList: [], error: null), firstPageKey: '@start');
PagingController<String, SCNMessage> _pagingController = PagingController.fromValue(PagingState(nextPageKey: null, itemList: [], error: null), firstPageKey: '@start');
Map<String, Channel>? _channels = null;
bool _isInitialized = false;
List<MessageFilterChiplet> _filterChiplets = [];
@override
void initState() {
super.initState();
AppEvents().subscribeFilterListener(_onAddFilter);
AppEvents().subscribeMessageReceivedListener(_onMessageReceivedViaNotification);
_pagingController.addPageRequestListener(_fetchPage);
if (widget.isVisiblePage && !_isInitialized) _realInitState();
@@ -64,18 +67,15 @@ class _MessageListPageState extends State<MessageListPage> with RouteAware {
void _realInitState() {
ApplicationLog.debug('MessageListPage::_realInitState');
final chnCache = Hive.box<Channel>('scn-channel-cache');
final msgCache = Hive.box<Message>('scn-message-cache');
if (chnCache.isNotEmpty && msgCache.isNotEmpty) {
if (SCNDataCache().hasMessagesAndChannels()) {
// ==== Use cache values - and refresh in background
_channels = <String, Channel>{for (var v in chnCache.values) v.channelID: v};
_channels = SCNDataCache().getChannelMap();
final cacheMessages = msgCache.values.toList();
cacheMessages.sort((a, b) => -1 * a.timestamp.compareTo(b.timestamp));
_pagingController.value = PagingState(nextPageKey: null, itemList: cacheMessages, error: null);
//TODO this is not 100% correct - the message-cache contains (which is right!) all messages, even from unsubscribed channels
//TODO what we should do is save another list in SCNDataCache, with the result of the last getMessageList call (page-1) and use that
//TODO this way we only get 1 page of data from cache, but its a weird behaviour anway that we loose data once _backgroundRefresh is finished
_pagingController.value = PagingState(nextPageKey: null, itemList: SCNDataCache().getMessagesSorted(), error: null);
_backgroundRefresh(true);
} else {
@@ -95,6 +95,8 @@ class _MessageListPageState extends State<MessageListPage> with RouteAware {
@override
void dispose() {
ApplicationLog.debug('MessageListPage::dispose');
AppEvents().unsubscribeFilterListener(_onAddFilter);
AppEvents().unsubscribeMessageReceivedListener(_onMessageReceivedViaNotification);
Navi.modalRouteObserver.unsubscribe(this);
_pagingController.dispose();
_lifecyleListener.dispose();
@@ -108,17 +110,22 @@ class _MessageListPageState extends State<MessageListPage> with RouteAware {
@override
void didPopNext() {
if (AppSettings().backgroundRefreshMessageListOnPop) {
ApplicationLog.debug('[MessageList::RouteObserver] --> didPopNext (will background-refresh)');
_backgroundRefresh(false);
}
}
void _onLifecycleResume() {
if (AppSettings().alwaysBackgroundRefreshMessageListOnLifecycleResume && widget.isVisiblePage) {
ApplicationLog.debug('[MessageList::_onLifecycleResume] --> (will background-refresh)');
_backgroundRefresh(false);
}
}
Future<void> _fetchPage(String thisPageToken) async {
final acc = Provider.of<AppAuth>(context, listen: false);
final cfg = Provider.of<AppSettings>(context, listen: false);
ApplicationLog.debug('Start MessageList::_pagingController::_fetchPage [ ${thisPageToken} ]');
@@ -132,12 +139,12 @@ class _MessageListPageState extends State<MessageListPage> with RouteAware {
final channels = await APIClient.getChannelList(acc, ChannelSelector.allAny);
_channels = <String, Channel>{for (var v in channels) v.channel.channelID: v.channel};
_setChannelCache(channels); // no await
SCNDataCache().setChannelCache(channels); // no await
}
final (npt, newItems) = await APIClient.getMessageList(acc, thisPageToken, pageSize: _pageSize);
final (npt, newItems) = await APIClient.getMessageList(acc, thisPageToken, pageSize: cfg.messagePageSize, filter: _getFilter());
_addToMessageCache(newItems); // no await
SCNDataCache().addToMessageCache(newItems); // no await
ApplicationLog.debug('Finished MessageList::_pagingController::_fetchPage [ ${newItems.length} items and npt: ${thisPageToken} --> ${npt} ]');
@@ -154,6 +161,7 @@ class _MessageListPageState extends State<MessageListPage> with RouteAware {
Future<void> _backgroundRefresh(bool fullReplaceState) async {
final acc = Provider.of<AppAuth>(context, listen: false);
final cfg = Provider.of<AppSettings>(context, listen: false);
ApplicationLog.debug('Start background refresh of message list (fullReplaceState: $fullReplaceState)');
@@ -167,12 +175,12 @@ class _MessageListPageState extends State<MessageListPage> with RouteAware {
setState(() {
_channels = <String, Channel>{for (var v in channels) v.channel.channelID: v.channel};
});
_setChannelCache(channels); // no await
SCNDataCache().setChannelCache(channels); // no await
}
final (npt, newItems) = await APIClient.getMessageList(acc, '@start', pageSize: _pageSize);
final (npt, newItems) = await APIClient.getMessageList(acc, '@start', pageSize: cfg.messagePageSize);
_addToMessageCache(newItems); // no await
SCNDataCache().addToMessageCache(newItems); // no await
if (fullReplaceState) {
// fully replace/reset state
@@ -221,49 +229,106 @@ class _MessageListPageState extends State<MessageListPage> with RouteAware {
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.fromLTRB(8, 4, 8, 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (_filterChiplets.isNotEmpty)
Wrap(
alignment: WrapAlignment.start,
spacing: 5.0,
children: [
for (var chiplet in _filterChiplets) _buildFilterChip(context, chiplet),
],
),
Expanded(
child: RefreshIndicator(
onRefresh: () => Future.sync(
() => _pagingController.refresh(),
),
child: PagedListView<String, Message>(
child: PagedListView<String, SCNMessage>(
pagingController: _pagingController,
builderDelegate: PagedChildBuilderDelegate<Message>(
builderDelegate: PagedChildBuilderDelegate<SCNMessage>(
itemBuilder: (context, item, index) => MessageListItem(
message: item,
allChannels: _channels ?? {},
onPressed: () {
Navi.push(context, () => MessageViewPage(message: item));
Navi.push(context, () => MessageViewPage(messageID: item.messageID, preloadedData: (item,)));
},
),
),
),
),
),
],
),
);
}
Future<void> _setChannelCache(List<ChannelWithSubscription> channels) async {
final cache = Hive.box<Channel>('scn-channel-cache');
if (cache.length != channels.length) await cache.clear();
for (var chn in channels) await cache.put(chn.channel.channelID, chn.channel);
Widget _buildFilterChip(BuildContext context, MessageFilterChiplet chiplet) {
return Padding(
padding: const EdgeInsets.fromLTRB(0, 2, 0, 2),
child: InputChip(
avatar: Icon(chiplet.icon()),
label: Text(chiplet.label),
onDeleted: () => _onRemFilter(chiplet),
onPressed: () {/* TODO idk what to do here ? */},
visualDensity: VisualDensity(horizontal: -4, vertical: -4),
),
);
}
Future<void> _addToMessageCache(List<Message> newItems) async {
final cache = Hive.box<Message>('scn-message-cache');
void _onAddFilter(List<MessageFilterChipletType> remTypeList, List<MessageFilterChiplet> chiplets) {
setState(() {
final remTypes = remTypeList.toSet();
for (var msg in newItems) await cache.put(msg.messageID, msg);
_filterChiplets = _filterChiplets.where((element) => !remTypes.contains(element.type)).toList() + chiplets;
// delete all but the newest 128 messages
if (cache.length < _pageSize) return;
final allValues = cache.values.toList();
allValues.sort((a, b) => -1 * a.timestamp.compareTo(b.timestamp));
for (var val in allValues.sublist(_pageSize)) {
await cache.delete(val.messageID);
_pagingController.refresh();
});
}
void _onRemFilter(MessageFilterChiplet chiplet) {
setState(() {
_filterChiplets.remove(chiplet);
_pagingController.refresh();
});
}
void _onMessageReceivedViaNotification(SCNMessage msg) {
setState(() {
_pagingController.itemList = [msg] + (_pagingController.itemList ?? []);
});
}
MessageFilter _getFilter() {
var filter = MessageFilter();
var chipletsChannel = _filterChiplets.where((p) => p.type == MessageFilterChipletType.channel).toList();
if (chipletsChannel.isNotEmpty) {
filter.channelIDs = chipletsChannel.map((p) => p.value as String).toList();
}
var chipletsSearch = _filterChiplets.where((p) => p.type == MessageFilterChipletType.search).toList();
if (chipletsSearch.isNotEmpty) {
filter.searchFilter = chipletsSearch.map((p) => p.value as String).toList();
}
var chipletsKeyTokens = _filterChiplets.where((p) => p.type == MessageFilterChipletType.sendkey).toList();
if (chipletsKeyTokens.isNotEmpty) {
filter.usedKeys = chipletsKeyTokens.map((p) => p.value as String).toList();
}
var chipletPriority = _filterChiplets.where((p) => p.type == MessageFilterChipletType.priority).toList();
if (chipletPriority.isNotEmpty) {
filter.priority = chipletPriority.map((p) => p.value as int).toList();
}
var chipletSender = _filterChiplets.where((p) => p.type == MessageFilterChipletType.sender).toList();
if (chipletSender.isNotEmpty) {
filter.senderNames = chipletSender.map((p) => p.value as String).toList();
}
return filter;
}
}

View File

@@ -3,7 +3,7 @@ import 'dart:math';
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:simplecloudnotifier/models/channel.dart';
import 'package:simplecloudnotifier/models/message.dart';
import 'package:simplecloudnotifier/models/scn_message.dart';
import 'package:intl/intl.dart';
import 'package:simplecloudnotifier/utils/ui.dart';
@@ -18,7 +18,7 @@ class MessageListItem extends StatelessWidget {
super.key,
});
final Message message;
final SCNMessage message;
final Map<String, Channel> allChannels;
final Null Function() onPressed;
@@ -176,11 +176,11 @@ class MessageListItem extends StatelessWidget {
return v;
}
String resolveChannelName(Message message) {
String resolveChannelName(SCNMessage message) {
return allChannels[message.channelID]?.displayName ?? message.channelInternalName;
}
bool showChannel(Message message) {
bool showChannel(SCNMessage message) {
return message.channelInternalName != 'main';
}
}

View File

@@ -8,36 +8,49 @@ import 'package:simplecloudnotifier/api/api_client.dart';
import 'package:simplecloudnotifier/components/layout/scaffold.dart';
import 'package:simplecloudnotifier/models/channel.dart';
import 'package:simplecloudnotifier/models/keytoken.dart';
import 'package:simplecloudnotifier/models/message.dart';
import 'package:simplecloudnotifier/models/scn_message.dart';
import 'package:simplecloudnotifier/models/user.dart';
import 'package:simplecloudnotifier/pages/channel_view/channel_view.dart';
import 'package:simplecloudnotifier/state/app_auth.dart';
import 'package:simplecloudnotifier/state/app_bar_state.dart';
import 'package:simplecloudnotifier/utils/navi.dart';
import 'package:simplecloudnotifier/utils/toaster.dart';
import 'package:simplecloudnotifier/utils/ui.dart';
class MessageViewPage extends StatefulWidget {
const MessageViewPage({super.key, required this.message});
const MessageViewPage({
super.key,
required this.messageID,
required this.preloadedData,
});
final Message message; // Potentially trimmed
final String messageID; // Potentially trimmed
final (SCNMessage,)? preloadedData; // Message is potentially trimmed, whole object is potentially null
@override
State<MessageViewPage> createState() => _MessageViewPageState();
}
class _MessageViewPageState extends State<MessageViewPage> {
late Future<(Message, ChannelPreview, KeyTokenPreview, UserPreview)>? mainFuture;
(Message, ChannelPreview, KeyTokenPreview, UserPreview)? mainFutureSnapshot = null;
late Future<(SCNMessage, ChannelPreview, KeyTokenPreview, UserPreview)>? mainFuture;
(SCNMessage, ChannelPreview, KeyTokenPreview, UserPreview)? mainFutureSnapshot = null;
static final _dateFormat = DateFormat('yyyy-MM-dd kk:mm');
bool _monospaceMode = false;
SCNMessage? message = null;
@override
void initState() {
if (widget.preloadedData != null) {
message = widget.preloadedData!.$1;
}
mainFuture = fetchData();
super.initState();
}
Future<(Message, ChannelPreview, KeyTokenPreview, UserPreview)> fetchData() async {
Future<(SCNMessage, ChannelPreview, KeyTokenPreview, UserPreview)> fetchData() async {
try {
await Future.delayed(const Duration(seconds: 0), () {}); // this is annoyingly important - otherwise we call setLoadingIndeterminate directly in initStat() and get an exception....
@@ -45,7 +58,7 @@ class _MessageViewPageState extends State<MessageViewPage> {
final acc = Provider.of<AppAuth>(context, listen: false);
final msg = await APIClient.getMessage(acc, widget.message.messageID);
final msg = await APIClient.getMessage(acc, widget.messageID);
final fut_chn = APIClient.getChannelPreview(acc, msg.channelID);
final fut_key = APIClient.getKeyTokenPreview(acc, msg.usedKeyID);
@@ -79,7 +92,7 @@ class _MessageViewPageState extends State<MessageViewPage> {
showSearch: false,
showShare: true,
onShare: _share,
child: FutureBuilder<(Message, ChannelPreview, KeyTokenPreview, UserPreview)>(
child: FutureBuilder<(SCNMessage, ChannelPreview, KeyTokenPreview, UserPreview)>(
future: mainFuture,
builder: (context, snapshot) {
if (snapshot.hasData) {
@@ -87,8 +100,8 @@ class _MessageViewPageState extends State<MessageViewPage> {
return _buildMessageView(context, msg, chn, tok, usr);
} else if (snapshot.hasError) {
return Center(child: Text('${snapshot.error}')); //TODO nice error page
} else if (!widget.message.trimmed) {
return _buildMessageView(context, widget.message, null, null, null);
} else if (message != null && !this.message!.trimmed) {
return _buildMessageView(context, this.message!, null, null, null);
} else {
return const Center(child: CircularProgressIndicator());
}
@@ -98,7 +111,9 @@ class _MessageViewPageState extends State<MessageViewPage> {
}
void _share() async {
var msg = widget.message;
if (this.message == null) return;
var msg = this.message!;
if (mainFutureSnapshot != null) {
(msg, _, _, _) = mainFutureSnapshot!;
}
@@ -118,7 +133,7 @@ class _MessageViewPageState extends State<MessageViewPage> {
}
}
Widget _buildMessageView(BuildContext context, Message message, ChannelPreview? channel, KeyTokenPreview? token, UserPreview? user) {
Widget _buildMessageView(BuildContext context, SCNMessage message, ChannelPreview? channel, KeyTokenPreview? token, UserPreview? user) {
final userAccUserID = context.select<AppAuth, String?>((v) => v.userID);
return SingleChildScrollView(
@@ -131,12 +146,58 @@ class _MessageViewPageState extends State<MessageViewPage> {
SizedBox(height: 8),
if (message.content != null) ..._buildMessageContent(context, message),
SizedBox(height: 8),
if (message.senderName != null) _buildMetaCard(context, FontAwesomeIcons.solidSignature, 'Sender', [message.senderName!], () => {/*TODO*/}),
_buildMetaCard(context, FontAwesomeIcons.solidGearCode, 'KeyToken', [message.usedKeyID, if (token != null) token.name], () => {/*TODO*/}),
_buildMetaCard(context, FontAwesomeIcons.solidIdCardClip, 'MessageID', [message.messageID, if (message.userMessageID != null) message.userMessageID!], null),
_buildMetaCard(context, FontAwesomeIcons.solidSnake, 'Channel', [message.channelID, channel?.displayName ?? message.channelInternalName], () => {/*TODO*/}),
_buildMetaCard(context, FontAwesomeIcons.solidTimer, 'Timestamp', [message.timestamp], null),
_buildMetaCard(context, FontAwesomeIcons.solidUser, 'User', [if (user != null) user.userID, if (user?.username != null) user!.username!], () => {/*TODO*/}), //TODO
if (message.senderName != null)
UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidSignature,
title: 'Sender',
values: [message.senderName!],
mainAction: () => {/*TODO*/},
),
UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidGearCode,
title: 'KeyToken',
values: [message.usedKeyID, token?.name ?? '...'],
mainAction: () => {/*TODO*/},
),
UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidIdCardClip,
title: 'MessageID',
values: [message.messageID, message.userMessageID ?? ''],
),
UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidSnake,
title: 'Channel',
values: [message.channelID, channel?.displayName ?? message.channelInternalName],
mainAction: (channel != null)
? () {
Navi.push(context, () => ChannelViewPage(channelID: channel.channelID, preloadedData: null, needsReload: null));
}
: null,
),
UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidTimer,
title: 'Timestamp',
values: [message.timestamp],
),
UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidUser,
title: 'User',
values: [user?.userID ?? '...', user?.username ?? ''],
mainAction: () => {/*TODO*/},
),
UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidBolt,
title: 'Priority',
values: [_prettyPrintPriority(message.priority)],
mainAction: () => {/*TODO*/},
),
if (message.senderUserID == userAccUserID) UI.button(text: "Delete Message", onPressed: () {/*TODO*/}, color: Colors.red[900]),
],
),
@@ -144,11 +205,11 @@ class _MessageViewPageState extends State<MessageViewPage> {
);
}
String _resolveChannelName(ChannelPreview? channel, Message message) {
String _resolveChannelName(ChannelPreview? channel, SCNMessage message) {
return channel?.displayName ?? message.channelInternalName;
}
List<Widget> _buildMessageHeader(BuildContext context, Message message, ChannelPreview? channel) {
List<Widget> _buildMessageHeader(BuildContext context, SCNMessage message, ChannelPreview? channel) {
return [
Row(
children: [
@@ -167,7 +228,7 @@ class _MessageViewPageState extends State<MessageViewPage> {
];
}
List<Widget> _buildMessageContent(BuildContext context, Message message) {
List<Widget> _buildMessageContent(BuildContext context, SCNMessage message) {
return [
Row(
children: [
@@ -178,6 +239,7 @@ class _MessageViewPageState extends State<MessageViewPage> {
onPressed: () {
Clipboard.setData(new ClipboardData(text: message.content ?? ''));
Toaster.info("Clipboard", 'Copied text to Clipboard');
print('================= [CLIPBOARD] =================\n${message.content}\n================= [/CLIPBOARD] =================');
},
icon: FontAwesomeIcons.copy,
),
@@ -213,43 +275,20 @@ class _MessageViewPageState extends State<MessageViewPage> {
];
}
Widget _buildMetaCard(BuildContext context, IconData icn, String title, List<String> values, void Function()? action) {
final container = UI.box(
context: context,
padding: EdgeInsets.fromLTRB(16, 2, 4, 2),
child: Row(
children: [
FaIcon(icn, size: 18),
SizedBox(width: 16),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
for (final val in values) Text(val, style: const TextStyle(fontSize: 14)),
],
),
],
),
);
if (action == null) {
return Padding(
padding: EdgeInsets.symmetric(vertical: 4, horizontal: 0),
child: container,
);
} else {
return Padding(
padding: EdgeInsets.symmetric(vertical: 4, horizontal: 0),
child: InkWell(
splashColor: Theme.of(context).splashColor,
onTap: action,
child: container,
),
);
}
}
String _preformatTitle(Message message) {
String _preformatTitle(SCNMessage message) {
return message.title.replaceAll('\n', '').replaceAll('\r', '').replaceAll('\t', ' ');
}
String _prettyPrintPriority(int priority) {
switch (priority) {
case 0:
return 'Low (0)';
case 1:
return 'Normal (1)';
case 2:
return 'High (2)';
default:
return 'Unknown ($priority)';
}
}
}

View File

@@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
class AppSettings extends ChangeNotifier {
bool groupNotifications = true;
int messagePageSize = 128;
bool showDebugButton = true;
bool backgroundRefreshMessageListOnPop = false;
bool alwaysBackgroundRefreshMessageListOnLifecycleResume = true;
static AppSettings? _singleton = AppSettings._internal();
factory AppSettings() {
return _singleton ?? (_singleton = AppSettings._internal());
}
AppSettings._internal() {
load();
}
void clear() {
//TODO
notifyListeners();
}
void load() {
//TODO
notifyListeners();
}
Future<void> save() async {
//TODO
}
}

View File

@@ -1,8 +1,11 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:simplecloudnotifier/api/api_client.dart';
import 'package:simplecloudnotifier/api/api_exception.dart';
import 'package:simplecloudnotifier/models/client.dart';
import 'package:simplecloudnotifier/models/user.dart';
import 'package:simplecloudnotifier/state/application_log.dart';
import 'package:simplecloudnotifier/state/globals.dart';
import 'package:simplecloudnotifier/state/token_source.dart';
@@ -12,9 +15,9 @@ class AppAuth extends ChangeNotifier implements TokenSource {
String? _tokenAdmin;
String? _tokenSend;
User? _user;
Client? _client;
DateTime? _clientQueryTime;
(User, DateTime)? _user;
(Client, DateTime)? _client;
String? get userID => _userID;
String? get tokenAdmin => _tokenAdmin;
@@ -35,17 +38,21 @@ class AppAuth extends ChangeNotifier implements TokenSource {
}
void set(User user, Client client, String tokenAdmin, String tokenSend) {
_client = client;
_user = user;
_client = (client, DateTime.now());
_user = (user, DateTime.now());
_userID = user.userID;
_clientID = client.clientID;
_tokenAdmin = tokenAdmin;
_tokenSend = tokenSend;
notifyListeners();
}
void setClientAndClientID(Client client) {
_client = client;
_client = (client, DateTime.now());
_clientID = client.clientID;
notifyListeners();
}
@@ -83,6 +90,33 @@ class AppAuth extends ChangeNotifier implements TokenSource {
_client = null;
_user = null;
final userjson = Globals().sharedPrefs.getString('auth.user.obj');
final userqdate = Globals().sharedPrefs.getString('auth.user.qdate');
final clientjson = Globals().sharedPrefs.getString('auth.client.obj');
final clientqdate = Globals().sharedPrefs.getString('auth.client.qdate');
if (userjson != null && userqdate != null) {
try {
final ts = DateTime.parse(userqdate);
final obj = User.fromJson(jsonDecode(userjson) as Map<String, dynamic>);
_user = (obj, ts);
} catch (exc, trace) {
ApplicationLog.error('failed to parse user object from shared-prefs (auth.user.obj): ' + exc.toString(), additional: 'Data:\n${userjson}\nQDate:\n${userqdate}', trace: trace);
_user = null;
}
}
if (clientjson != null && clientqdate != null) {
try {
final ts = DateTime.parse(clientqdate);
final obj = Client.fromJson(jsonDecode(clientjson) as Map<String, dynamic>);
_client = (obj, ts);
} catch (exc, trace) {
ApplicationLog.error('failed to parse user object from shared-prefs (auth.client.obj): ' + exc.toString(), additional: 'Data:\n${clientjson}\nQDate:\n${clientqdate}', trace: trace);
_client = null;
}
}
notifyListeners();
}
@@ -94,6 +128,10 @@ class AppAuth extends ChangeNotifier implements TokenSource {
await Globals().sharedPrefs.remove('auth.tokensend');
await Globals().sharedPrefs.setString('auth.cdate', "");
await Globals().sharedPrefs.setString('auth.mdate', DateTime.now().toIso8601String());
await Globals().sharedPrefs.remove('auth.user.obj');
await Globals().sharedPrefs.remove('auth.user.qdate');
await Globals().sharedPrefs.remove('auth.client.obj');
await Globals().sharedPrefs.remove('auth.client.qdate');
} else {
await Globals().sharedPrefs.setString('auth.userid', _userID!);
await Globals().sharedPrefs.setString('auth.clientid', _clientID!);
@@ -101,14 +139,34 @@ class AppAuth extends ChangeNotifier implements TokenSource {
await Globals().sharedPrefs.setString('auth.tokensend', _tokenSend!);
if (Globals().sharedPrefs.getString('auth.cdate') == null) await Globals().sharedPrefs.setString('auth.cdate', DateTime.now().toIso8601String());
await Globals().sharedPrefs.setString('auth.mdate', DateTime.now().toIso8601String());
if (_user != null) {
await Globals().sharedPrefs.setString('auth.user.obj', jsonEncode(_user!.$1.toJson()));
await Globals().sharedPrefs.setString('auth.user.qdate', _user!.$2.toIso8601String());
} else {
await Globals().sharedPrefs.remove('auth.user.obj');
await Globals().sharedPrefs.remove('auth.user.qdate');
}
if (_client != null) {
await Globals().sharedPrefs.setString('auth.client.obj', jsonEncode(_client!.$1.toJson()));
await Globals().sharedPrefs.setString('auth.client.qdate', _client!.$2.toIso8601String());
} else {
await Globals().sharedPrefs.remove('auth.client.obj');
await Globals().sharedPrefs.remove('auth.client.qdate');
}
}
Globals().sharedPrefs.setString('auth.mdate', DateTime.now().toIso8601String());
}
Future<User> loadUser({bool force = false}) async {
if (!force && _user != null && _user!.userID == _userID) {
return _user!;
Future<User> loadUser({bool force = false, Duration? forceIfOlder = null}) async {
if (forceIfOlder != null && _user != null && _user!.$2.difference(DateTime.now()) > forceIfOlder) {
force = true;
}
if (!force && _user != null && _user!.$1.userID == _userID) {
return _user!.$1;
}
if (_userID == null || _tokenAdmin == null) {
@@ -117,20 +175,24 @@ class AppAuth extends ChangeNotifier implements TokenSource {
final user = await APIClient.getUser(this, _userID!);
_user = user;
_user = (user, DateTime.now());
await save();
return user;
}
User? getUserOrNull() {
return _user?.$1;
}
Future<Client?> loadClient({bool force = false, Duration? forceIfOlder = null}) async {
if (forceIfOlder != null && _clientQueryTime != null && _clientQueryTime!.difference(DateTime.now()) > forceIfOlder) {
if (forceIfOlder != null && _client != null && _client!.$2.difference(DateTime.now()) > forceIfOlder) {
force = true;
}
if (!force && _client != null && _client!.clientID == _clientID) {
return _client!;
if (!force && _client != null && _client!.$1.clientID == _clientID) {
return _client!.$1;
}
if (_clientID == null || _tokenAdmin == null) {
@@ -140,7 +202,7 @@ class AppAuth extends ChangeNotifier implements TokenSource {
try {
final client = await APIClient.getClient(this, _clientID!);
_client = client;
_client = (client, DateTime.now());
await save();
@@ -154,6 +216,10 @@ class AppAuth extends ChangeNotifier implements TokenSource {
}
}
Client? getClientOrNull() {
return _client?.$1;
}
@override
String getToken() {
return _tokenAdmin!;

View File

@@ -12,9 +12,18 @@ class AppBarState extends ChangeNotifier {
bool _loadingIndeterminate = false;
bool get loadingIndeterminate => _loadingIndeterminate;
bool _showSearchField = false;
bool get showSearchField => _showSearchField;
void setLoadingIndeterminate(bool v) {
if (_loadingIndeterminate == v) return;
_loadingIndeterminate = v;
notifyListeners();
}
void setShowSearchField(bool v) {
if (_showSearchField == v) return;
_showSearchField = v;
notifyListeners();
}
}

View File

@@ -0,0 +1,53 @@
import 'package:simplecloudnotifier/models/scn_message.dart';
import 'package:simplecloudnotifier/pages/message_list/message_filter_chiplet.dart';
import 'package:simplecloudnotifier/state/application_log.dart';
class AppEvents {
static AppEvents? _singleton = AppEvents._internal();
factory AppEvents() {
return _singleton ?? (_singleton = AppEvents._internal());
}
AppEvents._internal() {}
// --------------------------------------------------------------------------
List<void Function(List<MessageFilterChipletType> types, List<MessageFilterChiplet>)> _filterListeners = [];
void subscribeFilterListener(void Function(List<MessageFilterChipletType> types, List<MessageFilterChiplet>) listener) {
_filterListeners.add(listener);
}
void unsubscribeFilterListener(void Function(List<MessageFilterChipletType> types, List<MessageFilterChiplet>) listener) {
_filterListeners.remove(listener);
}
void notifyFilterListeners(List<MessageFilterChipletType> types, List<MessageFilterChiplet> query) {
ApplicationLog.debug('[AppEvents] onFilter: [${types.join(" ; ")}], [${query.map((e) => e.label).join('|')}]');
for (var listener in _filterListeners) {
listener(types, query);
}
}
// --------------------------------------------------------------------------
List<void Function(SCNMessage)> _messageReceivedListeners = [];
void subscribeMessageReceivedListener(void Function(SCNMessage) listener) {
_messageReceivedListeners.add(listener);
}
void unsubscribeMessageReceivedListener(void Function(SCNMessage) listener) {
_messageReceivedListeners.remove(listener);
}
void notifyMessageReceivedListeners(SCNMessage msg) {
ApplicationLog.debug('[AppEvents] onMessageReceived: ${msg.messageID}');
for (var listener in _messageReceivedListeners) {
listener(msg);
}
}
}

View File

@@ -1,6 +1,11 @@
import 'dart:convert';
import 'dart:io';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:simplecloudnotifier/state/globals.dart';
import 'package:simplecloudnotifier/state/interfaces.dart';
import 'package:xid/xid.dart';
import 'package:path/path.dart' as path;
part 'application_log.g.dart';
@@ -10,6 +15,7 @@ class ApplicationLog {
static void debug(String message, {String? additional, StackTrace? trace}) {
(additional != null && additional != '') ? print('[DEBUG] ${message}: ${additional}') : print('[DEBUG] ${message}');
if (!Hive.isBoxOpen('scn-logs')) return;
Hive.box<SCNLog>('scn-logs').add(SCNLog(
id: Xid().toString(),
timestamp: DateTime.now(),
@@ -23,6 +29,7 @@ class ApplicationLog {
static void info(String message, {String? additional, StackTrace? trace}) {
(additional != null && additional != '') ? print('[INFO] ${message}: ${additional}') : print('[INFO] ${message}');
if (!Hive.isBoxOpen('scn-logs')) return;
Hive.box<SCNLog>('scn-logs').add(SCNLog(
id: Xid().toString(),
timestamp: DateTime.now(),
@@ -36,6 +43,7 @@ class ApplicationLog {
static void warn(String message, {String? additional, StackTrace? trace}) {
(additional != null && additional != '') ? print('[WARN] ${message}: ${additional}') : print('[WARN] ${message}');
if (!Hive.isBoxOpen('scn-logs')) return;
Hive.box<SCNLog>('scn-logs').add(SCNLog(
id: Xid().toString(),
timestamp: DateTime.now(),
@@ -49,6 +57,7 @@ class ApplicationLog {
static void error(String message, {String? additional, StackTrace? trace}) {
(additional != null && additional != '') ? print('[ERROR] ${message}: ${additional}') : print('[ERROR] ${message}');
if (!Hive.isBoxOpen('scn-logs')) return;
Hive.box<SCNLog>('scn-logs').add(SCNLog(
id: Xid().toString(),
timestamp: DateTime.now(),
@@ -62,6 +71,7 @@ class ApplicationLog {
static void fatal(String message, {String? additional, StackTrace? trace}) {
(additional != null && additional != '') ? print('[FATAL] ${message}: ${additional}') : print('[FATAL] ${message}');
if (!Hive.isBoxOpen('scn-logs')) return;
Hive.box<SCNLog>('scn-logs').add(SCNLog(
id: Xid().toString(),
timestamp: DateTime.now(),
@@ -71,6 +81,61 @@ class ApplicationLog {
trace: trace?.toString() ?? '',
));
}
static void writeRawFailure(String message, Map<String, dynamic> extraData) async {
try {
await Globals().init();
final fn = path.join(Globals().rawFailureLogsDir.path, 'failure-${DateTime.now().toIso8601String()}.log');
var txt = "[TEXT]\n${message}\n\n";
for (var k in extraData.keys) {
txt += "[${k}]\n${_debugToStr(extraData[k])}\n\n";
}
await File(fn).writeAsString(txt);
ApplicationLog.debug("Wrote raw failure log to '${fn}' ('${message}')");
} catch (e) {
print("Failed to <writeRawFailure>: ${e}");
}
}
static String _debugToStr(dynamic v) {
if (v is String) {
return v;
}
if (v is StackTrace) {
return v.toString();
}
try {
final enc = new JsonEncoder.withIndent(" ");
return enc.convert(v);
} catch (e) {
// ignore
}
try {
return jsonEncode(v);
} catch (e) {
// ignore
}
try {
return v.toString();
} catch (e) {
// ignore
}
try {
return "${v}";
} catch (e) {
// ignore
}
return "<[!]FAILED_TO_PRINT_OBJECT>";
}
}
@HiveType(typeId: 103)

View File

@@ -32,8 +32,6 @@ class FBMessage extends HiveObject implements FieldDebuggable {
final String? messageType;
@HiveField(8)
final bool mutableContent;
@HiveField(9)
final RemoteNotification? notification;
@HiveField(10)
final DateTime? sentTime;
@HiveField(11)
@@ -54,7 +52,7 @@ class FBMessage extends HiveObject implements FieldDebuggable {
@HiveField(25)
final String? notificationAndroidLink;
@HiveField(26)
final AndroidNotificationPriority? notificationAndroidPriority;
final String? notificationAndroidPriority;
@HiveField(27)
final String? notificationAndroidSmallIcon;
@HiveField(28)
@@ -62,14 +60,14 @@ class FBMessage extends HiveObject implements FieldDebuggable {
@HiveField(29)
final String? notificationAndroidTicker;
@HiveField(30)
final AndroidNotificationVisibility? notificationAndroidVisibility;
final String? notificationAndroidVisibility;
@HiveField(31)
final String? notificationAndroidTag;
@HiveField(40)
final String? notificationAppleBadge;
@HiveField(41)
final AppleNotificationSound? notificationAppleSound;
final String? notificationAppleSound;
@HiveField(42)
final String? notificationAppleImageUrl;
@HiveField(43)
@@ -109,7 +107,6 @@ class FBMessage extends HiveObject implements FieldDebuggable {
required this.messageId,
required this.messageType,
required this.mutableContent,
required this.notification,
required this.sentTime,
required this.threadId,
required this.ttl,
@@ -152,7 +149,6 @@ class FBMessage extends HiveObject implements FieldDebuggable {
this.messageId = rmsg.messageId,
this.messageType = rmsg.messageType,
this.mutableContent = rmsg.mutableContent,
this.notification = rmsg.notification,
this.sentTime = rmsg.sentTime,
this.threadId = rmsg.threadId,
this.ttl = rmsg.ttl,
@@ -162,14 +158,14 @@ class FBMessage extends HiveObject implements FieldDebuggable {
this.notificationAndroidCount = rmsg.notification?.android?.count,
this.notificationAndroidImageUrl = rmsg.notification?.android?.imageUrl,
this.notificationAndroidLink = rmsg.notification?.android?.link,
this.notificationAndroidPriority = rmsg.notification?.android?.priority,
this.notificationAndroidPriority = rmsg.notification?.android?.priority.toString(),
this.notificationAndroidSmallIcon = rmsg.notification?.android?.smallIcon,
this.notificationAndroidSound = rmsg.notification?.android?.sound,
this.notificationAndroidTicker = rmsg.notification?.android?.ticker,
this.notificationAndroidVisibility = rmsg.notification?.android?.visibility,
this.notificationAndroidVisibility = rmsg.notification?.android?.visibility.toString(),
this.notificationAndroidTag = rmsg.notification?.android?.tag,
this.notificationAppleBadge = rmsg.notification?.apple?.badge,
this.notificationAppleSound = rmsg.notification?.apple?.sound,
this.notificationAppleSound = rmsg.notification?.apple?.sound?.toString(),
this.notificationAppleImageUrl = rmsg.notification?.apple?.imageUrl,
this.notificationAppleSubtitle = rmsg.notification?.apple?.subtitle,
this.notificationAppleSubtitleLocArgs = rmsg.notification?.apple?.subtitleLocArgs,
@@ -195,12 +191,11 @@ class FBMessage extends HiveObject implements FieldDebuggable {
('category', this.category ?? ''),
('collapseKey', this.collapseKey ?? ''),
('contentAvailable', this.contentAvailable.toString()),
('data', this.data.toString()),
('data', this.data.entries.map((e) => '${e.key} := ${e.value}').join('\n')),
('from', this.from ?? ''),
('messageId', this.messageId ?? ''),
('messageType', this.messageType ?? ''),
('mutableContent', this.mutableContent.toString()),
('notification', this.notification?.toString() ?? ''),
('sentTime', this.sentTime?.toString() ?? ''),
('threadId', this.threadId ?? ''),
('ttl', this.ttl?.toString() ?? ''),

View File

@@ -26,7 +26,6 @@ class FBMessageAdapter extends TypeAdapter<FBMessage> {
messageId: fields[6] as String?,
messageType: fields[7] as String?,
mutableContent: fields[8] as bool,
notification: fields[9] as RemoteNotification?,
sentTime: fields[10] as DateTime?,
threadId: fields[11] as String?,
ttl: fields[12] as int?,
@@ -36,15 +35,14 @@ class FBMessageAdapter extends TypeAdapter<FBMessage> {
notificationAndroidCount: fields[23] as int?,
notificationAndroidImageUrl: fields[24] as String?,
notificationAndroidLink: fields[25] as String?,
notificationAndroidPriority: fields[26] as AndroidNotificationPriority?,
notificationAndroidPriority: fields[26] as String?,
notificationAndroidSmallIcon: fields[27] as String?,
notificationAndroidSound: fields[28] as String?,
notificationAndroidTicker: fields[29] as String?,
notificationAndroidVisibility:
fields[30] as AndroidNotificationVisibility?,
notificationAndroidVisibility: fields[30] as String?,
notificationAndroidTag: fields[31] as String?,
notificationAppleBadge: fields[40] as String?,
notificationAppleSound: fields[41] as AppleNotificationSound?,
notificationAppleSound: fields[41] as String?,
notificationAppleImageUrl: fields[42] as String?,
notificationAppleSubtitle: fields[43] as String?,
notificationAppleSubtitleLocArgs: (fields[44] as List?)?.cast<String>(),
@@ -64,7 +62,7 @@ class FBMessageAdapter extends TypeAdapter<FBMessage> {
@override
void write(BinaryWriter writer, FBMessage obj) {
writer
..writeByte(40)
..writeByte(39)
..writeByte(0)
..write(obj.senderId)
..writeByte(1)
@@ -83,8 +81,6 @@ class FBMessageAdapter extends TypeAdapter<FBMessage> {
..write(obj.messageType)
..writeByte(8)
..write(obj.mutableContent)
..writeByte(9)
..write(obj.notification)
..writeByte(10)
..write(obj.sentTime)
..writeByte(11)

View File

@@ -2,7 +2,9 @@ import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:path/path.dart' as path;
class Globals {
static final Globals _singleton = Globals._internal();
@@ -13,6 +15,8 @@ class Globals {
Globals._internal();
bool _initialized = false;
String appName = '';
String packageName = '';
String version = '';
@@ -24,7 +28,14 @@ class Globals {
late SharedPreferences sharedPrefs;
late Directory appDocumentsDir;
late Directory rawFailureLogsDir;
bool get isInitialized => _initialized;
Future<void> init() async {
if (_initialized) return;
PackageInfo packageInfo = await PackageInfo.fromPlatform();
this.appName = packageInfo.appName;
@@ -54,6 +65,13 @@ class Globals {
}
this.sharedPrefs = await SharedPreferences.getInstance();
this.appDocumentsDir = await getApplicationDocumentsDirectory();
this.rawFailureLogsDir = Directory(path.join(Globals().appDocumentsDir.path, "rawlogs"));
await this.rawFailureLogsDir.create(recursive: true);
this._initialized = true;
}
String? getPrefFCMToken() {

View File

@@ -0,0 +1,60 @@
import 'package:hive_flutter/hive_flutter.dart';
import 'package:simplecloudnotifier/models/channel.dart';
import 'package:simplecloudnotifier/models/scn_message.dart';
import 'package:simplecloudnotifier/settings/app_settings.dart';
class SCNDataCache {
SCNDataCache._internal();
static final SCNDataCache _instance = SCNDataCache._internal();
factory SCNDataCache() => _instance;
Future<void> addToMessageCache(List<SCNMessage> newItems) async {
final cfg = AppSettings();
final cache = Hive.box<SCNMessage>('scn-message-cache');
for (var msg in newItems) await cache.put(msg.messageID, msg);
// delete all but the newest 128 messages
if (cache.length < cfg.messagePageSize) return;
final allValues = cache.values.toList();
allValues.sort((a, b) => -1 * a.timestamp.compareTo(b.timestamp));
for (var val in allValues.sublist(cfg.messagePageSize)) {
await cache.delete(val.messageID);
}
}
Future<void> setChannelCache(List<ChannelWithSubscription> channels) async {
final cache = Hive.box<Channel>('scn-channel-cache');
if (cache.length != channels.length) await cache.clear();
for (var chn in channels) await cache.put(chn.channel.channelID, chn.channel);
}
bool hasMessagesAndChannels() {
final chnCache = Hive.box<Channel>('scn-channel-cache');
final msgCache = Hive.box<SCNMessage>('scn-message-cache');
return chnCache.isNotEmpty && msgCache.isNotEmpty;
}
Map<String, Channel> getChannelMap() {
final chnCache = Hive.box<Channel>('scn-channel-cache');
return <String, Channel>{for (var v in chnCache.values) v.channelID: v};
}
List<SCNMessage> getMessagesSorted() {
final msgCache = Hive.box<SCNMessage>('scn-message-cache');
final cacheMessages = msgCache.values.toList();
cacheMessages.sort((a, b) => -1 * a.timestamp.compareTo(b.timestamp));
return cacheMessages;
}
}

View File

@@ -1,18 +1,26 @@
// This class is useful togther with FutureBuilder
// Unfortunately Future.value(x) in FutureBuilder always results in one frame were snapshot.connectionState is waiting
// Whit way we can set the ImmediateFuture.value directly and circumvent that.
// This way we can set the ImmediateFuture.value directly and circumvent that.
class ImmediateFuture<T> {
final Future<T> future;
final T? value;
T? _futureValue = null;
ImmediateFuture(this.future, this.value);
ImmediateFuture.ofFuture(Future<T> v)
: future = v,
value = null;
value = null {
future.then((v) => _futureValue = v);
}
ImmediateFuture.ofValue(T v)
: future = Future.value(v),
value = v;
T? get() {
return value ?? _futureValue;
}
}

View File

@@ -0,0 +1,29 @@
import 'package:flutter/material.dart';
class UIDialogs {
static Future<String?> showTextInput(BuildContext context, String title, String hintText) {
var _textFieldController = TextEditingController();
return showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(title),
content: TextField(
autofocus: true,
controller: _textFieldController,
decoration: InputDecoration(hintText: hintText),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(_textFieldController.text),
child: Text('OK'),
),
],
),
);
}
}

View File

@@ -8,15 +8,21 @@ class Navi {
static void push<T extends Widget>(BuildContext context, T Function() builder) {
Provider.of<AppBarState>(context, listen: false).setLoadingIndeterminate(false);
Provider.of<AppBarState>(context, listen: false).setShowSearchField(false);
Navigator.push(context, MaterialPageRoute<T>(builder: (context) => builder()));
}
static void popToRoot(BuildContext context) {
Provider.of<AppBarState>(context, listen: false).setLoadingIndeterminate(false);
Provider.of<AppBarState>(context, listen: false).setShowSearchField(false);
Navigator.popUntil(context, (route) => route.isFirst);
}
static void popDialog(BuildContext dialogContext) {
Navigator.pop(dialogContext);
}
}
class SCNRouteObserver extends RouteObserver<PageRoute<dynamic>> {
@@ -25,6 +31,7 @@ class SCNRouteObserver extends RouteObserver<PageRoute<dynamic>> {
super.didPush(route, previousRoute);
if (route is PageRoute) {
AppBarState().setLoadingIndeterminate(false);
AppBarState().setShowSearchField(false);
print('[SCNRouteObserver] .didPush()');
}
@@ -35,6 +42,7 @@ class SCNRouteObserver extends RouteObserver<PageRoute<dynamic>> {
super.didReplace(newRoute: newRoute, oldRoute: oldRoute);
if (newRoute is PageRoute) {
AppBarState().setLoadingIndeterminate(false);
AppBarState().setShowSearchField(false);
print('[SCNRouteObserver] .didReplace()');
}
@@ -45,6 +53,7 @@ class SCNRouteObserver extends RouteObserver<PageRoute<dynamic>> {
super.didPop(route, previousRoute);
if (previousRoute is PageRoute && route is PageRoute) {
AppBarState().setLoadingIndeterminate(false);
AppBarState().setShowSearchField(false);
print('[SCNRouteObserver] .didPop()');
}

View File

@@ -0,0 +1,83 @@
import 'dart:io';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:simplecloudnotifier/settings/app_settings.dart';
import 'package:simplecloudnotifier/state/application_log.dart';
import 'package:simplecloudnotifier/state/globals.dart';
class Notifier {
static void showLocalNotification(String messageID, String channelID, String channelName, String channelDescr, String title, String body, DateTime? timestamp) async {
final nid = Globals().sharedPrefs.getInt('notifier.nextid') ?? 1000;
Globals().sharedPrefs.setInt('notifier.nextid', nid + 7);
final existingSummaryNID = Globals().sharedPrefs.getInt('notifier.summary.$channelID');
final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();
if (Platform.isAndroid && AppSettings().groupNotifications) {
final activeNotifications = (await flutterLocalNotificationsPlugin.getActiveNotifications()).where((p) => p.groupKey == channelID).toList();
final summaryNotification = activeNotifications.where((p) => p.id == existingSummaryNID).toList();
ApplicationLog.debug('found ${activeNotifications.length} active notifications in this group (${summaryNotification.length} summary notifications for channel ${channelID} with nid [${existingSummaryNID}])');
if (activeNotifications.isNotEmpty && !activeNotifications.any((p) => p.id == existingSummaryNID)) {
// ======== SHOW SUMMARY/GROUPING NOTIFICATION ========
final newSummaryNID = nid + 1;
ApplicationLog.debug('Create new summary notifications for channel ${channelID} with nid [${newSummaryNID}])');
Globals().sharedPrefs.setInt('notifier.summary.$channelID', newSummaryNID);
var payload = '';
if (messageID != '') {
payload = ['@SCN_MESSAGE_SUMMARY', channelID, newSummaryNID].join("\n");
}
await flutterLocalNotificationsPlugin.show(
newSummaryNID,
channelName,
"(multiple notifications)",
NotificationDetails(
android: AndroidNotificationDetails(
channelID,
channelName,
importance: Importance.max,
priority: Priority.high,
groupKey: channelID,
setAsGroupSummary: true,
subText: (channelName == 'main') ? null : channelName,
),
),
payload: payload,
);
}
}
final newMessageNID = nid + 2;
ApplicationLog.debug('Create new local notifications for message in channel ${channelID} with nid [${newMessageNID}])');
var payload = '';
if (messageID != '') {
payload = ['@SCN_MESSAGE', messageID, channelID, newMessageNID].join("\n");
}
// ======== SHOW NOTIFICATION ========
await flutterLocalNotificationsPlugin.show(
newMessageNID,
title,
body,
NotificationDetails(
android: AndroidNotificationDetails(
channelID,
channelName,
channelDescription: channelDescr,
importance: Importance.max,
priority: Priority.high,
when: timestamp?.millisecondsSinceEpoch,
groupKey: channelID,
subText: (channelName == 'main') ? null : channelName,
),
),
payload: payload,
);
}
}

View File

@@ -72,7 +72,7 @@ class UI {
splashColor: Theme.of(context).splashColor,
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(16),
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: child,
),
),
@@ -106,4 +106,49 @@ class UI {
child: child,
);
}
static Widget metaCard({required BuildContext context, required IconData icon, required String title, required List<String> values, void Function()? mainAction, List<(IconData, void Function())>? iconActions}) {
final container = UI.box(
context: context,
padding: EdgeInsets.fromLTRB(16, 2, 4, 2),
child: Row(
children: [
FaIcon(icon, size: 18),
SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
for (final val in values) Text(val, style: const TextStyle(fontSize: 14)),
],
),
),
if (iconActions != null) ...[
SizedBox(width: 12),
for (final iconAction in iconActions) ...[
SizedBox(width: 4),
IconButton(icon: FaIcon(iconAction.$1), onPressed: iconAction.$2),
],
],
],
),
);
if (mainAction == null) {
return Padding(
padding: EdgeInsets.symmetric(vertical: 4, horizontal: 0),
child: container,
);
} else {
return Padding(
padding: EdgeInsets.symmetric(vertical: 4, horizontal: 0),
child: InkWell(
splashColor: Theme.of(context).splashColor,
onTap: mainAction,
child: container,
),
);
}
}
}

View File

@@ -8,6 +8,8 @@ import Foundation
import device_info_plus
import firebase_core
import firebase_messaging
import flutter_local_notifications
import mobile_scanner
import package_info_plus
import path_provider_foundation
import share_plus
@@ -18,6 +20,8 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,7 @@ dependencies:
flutter:
sdk: flutter
flutter_launcher_icons: "^0.13.1"
flutter_launcher_icons: ^0.14.3
font_awesome_flutter: '>= 4.7.0'
cupertino_icons: ^1.0.2
@@ -21,20 +21,23 @@ dependencies:
qr_flutter: ^4.1.0
url_launcher: ^6.2.4
infinite_scroll_pagination: ^4.0.0
intl: ^0.19.0
intl: ^0.20.2
path_provider: ^2.1.3
hive_flutter: ^1.1.0
package_info_plus: ^8.0.0
xid: ^1.2.1
flutter_lazy_indexed_stack: ^0.0.6
firebase_core: ^2.32.0
firebase_messaging: ^14.9.4
device_info_plus: ^10.1.0
toastification: ^2.0.0
firebase_core: ^3.13.0
firebase_messaging: ^15.2.5
device_info_plus: ^11.3.0
toastification: ^3.0.1
uuid: ^4.4.0
share_plus: ^9.0.0
share_plus: ^10.1.4
flutter_local_notifications: ^17.2.3
path: any
mobile_scanner: ^6.0.1
dependency_overrides:
font_awesome_flutter:
path: deps/font_awesome_flutter
@@ -44,7 +47,7 @@ dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^4.0.0
flutter_lints: ^5.0.0
hive_generator: ^2.0.1
build_runner: ^2.1.4

View File

@@ -5,7 +5,7 @@ PORT=9090
NAMESPACE=$(shell git rev-parse --abbrev-ref HEAD)
HASH=$(shell git rev-parse HEAD)
TAGS="timetzdata sqlite_fts5 sqlite_foreign_keys"
TAGS="timetzdata"
.PHONY: test swagger pygmentize docker migrate dgi pygmentize lint docker

View File

@@ -411,13 +411,13 @@ func (h APIHandler) ListChannelMessages(pctx ginext.PreContext) ginext.HTTPRespo
type query struct {
PageSize *int `json:"page_size" form:"page_size"`
NextPageToken *string `json:"next_page_token" form:"next_page_token"`
Filter *string `json:"filter" form:"filter"`
Trimmed *bool `json:"trimmed" form:"trimmed"`
}
type response struct {
Messages []models.Message `json:"messages"`
NextPageToken string `json:"next_page_token"`
PageSize int `json:"page_size"`
TotalCount int64 `json:"total_count"`
}
var u uri
@@ -457,16 +457,16 @@ func (h APIHandler) ListChannelMessages(pctx ginext.PreContext) ginext.HTTPRespo
ChannelID: langext.Ptr([]models.ChannelID{channel.ChannelID}),
}
messages, npt, err := h.database.ListMessages(ctx, filter, &pageSize, tok)
messages, npt, totalCount, err := h.database.ListMessages(ctx, filter, &pageSize, tok)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query messages", err)
}
if trimmed {
res := langext.ArrMap(messages, func(v models.Message) models.Message { return v.Trim() })
return finishSuccess(ginext.JSON(http.StatusOK, response{Messages: res, NextPageToken: npt.Token(), PageSize: pageSize}))
return finishSuccess(ginext.JSON(http.StatusOK, response{Messages: res, NextPageToken: npt.Token(), PageSize: pageSize, TotalCount: totalCount}))
} else {
return finishSuccess(ginext.JSON(http.StatusOK, response{Messages: messages, NextPageToken: npt.Token(), PageSize: pageSize}))
return finishSuccess(ginext.JSON(http.StatusOK, response{Messages: messages, NextPageToken: npt.Token(), PageSize: pageSize, TotalCount: totalCount}))
}
})

View File

@@ -39,7 +39,8 @@ func (h APIHandler) ListMessages(pctx ginext.PreContext) ginext.HTTPResponse {
type query struct {
PageSize *int `json:"page_size" form:"page_size"`
NextPageToken *string `json:"next_page_token" form:"next_page_token"`
Filter *string `json:"filter" form:"filter"`
Search []string `json:"search" form:"search"`
StringSearch []string `json:"string_search" form:"string_search"`
Trimmed *bool `json:"trimmed" form:"trimmed"`
Channels []string `json:"channel" form:"channel"`
ChannelIDs []string `json:"channel_id" form:"channel_id"`
@@ -48,11 +49,13 @@ func (h APIHandler) ListMessages(pctx ginext.PreContext) ginext.HTTPResponse {
TimeAfter *string `json:"after" form:"after"` // RFC3339
Priority []int `json:"priority" form:"priority"`
KeyTokens []string `json:"used_key" form:"used_key"`
HasSender *bool `json:"has_sender" form:"has_sender"`
}
type response struct {
Messages []models.Message `json:"messages"`
NextPageToken string `json:"next_page_token"`
PageSize int `json:"page_size"`
TotalCount int64 `json:"total_count"`
}
var q query
@@ -90,8 +93,12 @@ func (h APIHandler) ListMessages(pctx ginext.PreContext) ginext.HTTPResponse {
ConfirmedSubscriptionBy: langext.Ptr(userid),
}
if q.Filter != nil && strings.TrimSpace(*q.Filter) != "" {
filter.SearchString = langext.Ptr([]string{strings.TrimSpace(*q.Filter)})
if len(q.Search) != 0 {
filter.SearchStringFTS = langext.Ptr(langext.ArrMap(q.Search, func(v string) string { return strings.TrimSpace(v) }))
}
if len(q.StringSearch) != 0 {
filter.SearchStringPlain = langext.Ptr(langext.ArrMap(q.StringSearch, func(v string) string { return strings.TrimSpace(v) }))
}
if len(q.Channels) != 0 {
@@ -114,6 +121,10 @@ func (h APIHandler) ListMessages(pctx ginext.PreContext) ginext.HTTPResponse {
filter.SenderNameCS = langext.Ptr(q.Senders)
}
if q.HasSender != nil {
filter.HasSenderName = langext.Ptr(*q.HasSender)
}
if q.TimeBefore != nil {
t0, err := time.Parse(time.RFC3339, *q.TimeBefore)
if err != nil {
@@ -146,17 +157,17 @@ func (h APIHandler) ListMessages(pctx ginext.PreContext) ginext.HTTPResponse {
filter.UsedKeyID = &tids
}
messages, npt, err := h.database.ListMessages(ctx, filter, &pageSize, tok)
messages, npt, totalCount, err := h.database.ListMessages(ctx, filter, &pageSize, tok)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query messages", err)
}
if trimmed {
res := langext.ArrMap(messages, func(v models.Message) models.Message { return v.PreMarshal().Trim() })
return finishSuccess(ginext.JSON(http.StatusOK, response{Messages: res, NextPageToken: npt.Token(), PageSize: pageSize}))
return finishSuccess(ginext.JSON(http.StatusOK, response{Messages: res, NextPageToken: npt.Token(), PageSize: pageSize, TotalCount: totalCount}))
} else {
res := langext.ArrMap(messages, func(v models.Message) models.Message { return v.PreMarshal() })
return finishSuccess(ginext.JSON(http.StatusOK, response{Messages: res, NextPageToken: npt.Token(), PageSize: pageSize}))
return finishSuccess(ginext.JSON(http.StatusOK, response{Messages: res, NextPageToken: npt.Token(), PageSize: pageSize, TotalCount: totalCount}))
}
})
}

View File

@@ -0,0 +1,102 @@
package handler
import (
"blackforestbytes.com/simplecloudnotifier/api/apierr"
"blackforestbytes.com/simplecloudnotifier/api/ginresp"
"blackforestbytes.com/simplecloudnotifier/logic"
"blackforestbytes.com/simplecloudnotifier/models"
"gogs.mikescher.com/BlackForestBytes/goext/ginext"
"net/http"
)
// ListUserSenderNames swaggerdoc
//
// @Summary List sender-names (of allthe messages of this user)
// @ID api-usersendernames-list
// @Tags API-v2
//
// @Param uid path string true "UserID"
//
// @Success 200 {object} handler.ListUserKeys.response
// @Failure 400 {object} ginresp.apiError "supplied values/parameters cannot be parsed / are invalid"
// @Failure 401 {object} ginresp.apiError "user is not authorized / has missing permissions"
// @Failure 404 {object} ginresp.apiError "message not found"
// @Failure 500 {object} ginresp.apiError "internal server error"
//
// @Router /api/v2/users/{uid}/keys [GET]
func (h APIHandler) ListUserSenderNames(pctx ginext.PreContext) ginext.HTTPResponse {
type uri struct {
UserID models.UserID `uri:"uid" binding:"entityid"`
}
type response struct {
SenderNames []models.SenderNameStatistics `json:"sender_names"`
}
var u uri
ctx, g, errResp := pctx.URI(&u).Start()
if errResp != nil {
return *errResp
}
defer ctx.Cancel()
return h.app.DoRequest(ctx, g, models.TLockRead, func(ctx *logic.AppContext, finishSuccess func(r ginext.HTTPResponse) ginext.HTTPResponse) ginext.HTTPResponse {
if permResp := ctx.CheckPermissionUserRead(u.UserID); permResp != nil {
return *permResp
}
names, err := h.database.ListSenderNames(ctx, u.UserID, false)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query messages", err)
}
return finishSuccess(ginext.JSON(http.StatusOK, response{SenderNames: names}))
})
}
// ListSenderNames swaggerdoc
//
// @Summary List sender-names (of all messages this user can view, eitehr own or foreign-subscribed)
// @ID api-sendernames-list
// @Tags API-v2
//
// @Success 200 {object} handler.ListSenderNames.response
// @Failure 400 {object} ginresp.apiError "supplied values/parameters cannot be parsed / are invalid"
// @Failure 401 {object} ginresp.apiError "user is not authorized / has missing permissions"
// @Failure 404 {object} ginresp.apiError "message not found"
// @Failure 500 {object} ginresp.apiError "internal server error"
//
// @Router /api/v2/sender-names [GET]
func (h APIHandler) ListSenderNames(pctx ginext.PreContext) ginext.HTTPResponse {
type response struct {
SenderNames []models.SenderNameStatistics `json:"sender_names"`
}
ctx, g, errResp := pctx.Start()
if errResp != nil {
return *errResp
}
defer ctx.Cancel()
return h.app.DoRequest(ctx, g, models.TLockRead, func(ctx *logic.AppContext, finishSuccess func(r ginext.HTTPResponse) ginext.HTTPResponse) ginext.HTTPResponse {
if permResp := ctx.CheckPermissionAny(); permResp != nil {
return *permResp
}
userID := *ctx.GetPermissionUserID()
if permResp := ctx.CheckPermissionUserRead(userID); permResp != nil {
return *permResp
}
names, err := h.database.ListSenderNames(ctx, userID, true)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query messages", err)
}
return finishSuccess(ginext.JSON(http.StatusOK, response{SenderNames: names}))
})
}

View File

@@ -80,7 +80,7 @@ func (h APIHandler) CreateUser(pctx ginext.PreContext) ginext.HTTPResponse {
sendKey := h.app.GenerateRandomAuthKey()
adminKey := h.app.GenerateRandomAuthKey()
err := h.database.ClearFCMTokens(ctx, b.FCMToken)
err := h.database.DeleteClientsByFCM(ctx, b.FCMToken)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to clear existing fcm tokens", err)
}

View File

@@ -9,7 +9,6 @@ import (
"bytes"
"errors"
"github.com/gin-gonic/gin"
"github.com/mattn/go-sqlite3"
"gogs.mikescher.com/BlackForestBytes/goext/ginext"
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"gogs.mikescher.com/BlackForestBytes/goext/timeext"
@@ -93,7 +92,6 @@ func (h CommonHandler) DatabaseTest(pctx ginext.PreContext) ginext.HTTPResponse
type response struct {
Success bool `json:"success"`
LibVersion string `json:"libVersion"`
LibVersionNumber int `json:"libVersionNumber"`
SourceID string `json:"sourceID"`
}
@@ -105,17 +103,19 @@ func (h CommonHandler) DatabaseTest(pctx ginext.PreContext) ginext.HTTPResponse
return h.app.DoRequest(ctx, g, models.TLockRead, func(ctx *logic.AppContext, finishSuccess func(r ginext.HTTPResponse) ginext.HTTPResponse) ginext.HTTPResponse {
libVersion, libVersionNumber, sourceID := sqlite3.Version()
versionStr, sourceID, err := h.app.Database.Primary.Version(ctx)
if err != nil {
return ginresp.InternalError(err)
}
err := h.app.Database.Ping(ctx)
err = h.app.Database.Ping(ctx)
if err != nil {
return ginresp.InternalError(err)
}
return ginext.JSON(http.StatusOK, response{
Success: true,
LibVersion: libVersion,
LibVersionNumber: libVersionNumber,
LibVersion: versionStr,
SourceID: sourceID,
})
@@ -145,12 +145,6 @@ func (h CommonHandler) Health(pctx ginext.PreContext) ginext.HTTPResponse {
return h.app.DoRequest(ctx, g, models.TLockReadWrite, func(ctx *logic.AppContext, finishSuccess func(r ginext.HTTPResponse) ginext.HTTPResponse) ginext.HTTPResponse {
_, libVersionNumber, _ := sqlite3.Version()
if libVersionNumber < 3039000 {
return ginresp.InternalError(errors.New("sqlite version too low"))
}
tctx := simplectx.CreateSimpleContext(ctx, nil)
err := h.app.Database.Ping(tctx)

View File

@@ -189,7 +189,7 @@ func (h CompatHandler) Register(pctx ginext.PreContext) ginext.HTTPResponse {
adminKey := h.app.GenerateRandomAuthKey()
err := h.database.ClearFCMTokens(ctx, *data.FCMToken)
err := h.database.DeleteClientsByFCM(ctx, *data.FCMToken)
if err != nil {
return ginresp.CompatAPIError(0, "Failed to clear existing fcm tokens")
}
@@ -538,7 +538,7 @@ func (h CompatHandler) Requery(pctx ginext.PreContext) ginext.HTTPResponse {
CompatAcknowledged: langext.Ptr(false),
}
msgs, _, err := h.database.ListMessages(ctx, filter, langext.Ptr(16), ct.Start())
msgs, _, _, err := h.database.ListMessages(ctx, filter, langext.Ptr(16), ct.Start())
if err != nil {
return ginresp.CompatAPIError(0, "Failed to query user")
}

View File

@@ -152,10 +152,14 @@ func (r *Router) Init(e *ginext.GinWrapper) error {
apiv2.DELETE("/users/:uid/subscriptions/:sid").Handle(r.apiHandler.CancelSubscription)
apiv2.PATCH("/users/:uid/subscriptions/:sid").Handle(r.apiHandler.UpdateSubscription)
apiv2.GET("/users/:uid/sender-names").Handle(r.apiHandler.ListUserSenderNames)
apiv2.GET("/messages").Handle(r.apiHandler.ListMessages)
apiv2.GET("/messages/:mid").Handle(r.apiHandler.GetMessage)
apiv2.DELETE("/messages/:mid").Handle(r.apiHandler.DeleteMessage)
apiv2.GET("/sender-names").Handle(r.apiHandler.ListSenderNames)
apiv2.GET("/preview/users/:uid").Handle(r.apiHandler.GetUserPreview)
apiv2.GET("/preview/keys/:kid").Handle(r.apiHandler.GetUserKeyPreview)
apiv2.GET("/preview/channels/:cid").Handle(r.apiHandler.GetChannelPreview)

View File

@@ -15,39 +15,46 @@ import (
func main() {
exerr.Init(exerr.ErrorPackageConfigInit{})
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 1011*time.Second)
defer cancel()
if !langext.InArray("sqlite3", sql.Drivers()) {
sqlite.RegisterAsSQLITE3()
}
fmt.Println()
for i := 2; i <= schema.PrimarySchemaVersion; i++ {
h0, err := sq.HashGoSqliteSchema(ctx, schema.PrimarySchema[i].SQL)
for key, schemaObj := range langext.AsSortedBy(langext.MapToArr(schema.PrimarySchema), func(v langext.MapEntry[int, schema.Def]) int { return v.Key }) {
var h0 string
if key == 1 {
h0 = "N/A"
} else {
var err error
h0, err = sq.HashGoSqliteSchema(ctx, schemaObj.Value.SQL)
if err != nil {
h0 = "ERR"
}
fmt.Printf("PrimarySchema%d := %s\n", i, h0)
}
fmt.Printf("PrimarySchema [%d] := %s%s\n", schemaObj.Key, h0, langext.Conditional(schemaObj.Key == schema.PrimarySchemaVersion, " (active)", ""))
}
for i := 1; i <= schema.RequestsSchemaVersion; i++ {
h0, err := sq.HashGoSqliteSchema(ctx, schema.RequestsSchema[i].SQL)
fmt.Printf("\n")
for _, schemaObj := range langext.AsSortedBy(langext.MapToArr(schema.RequestsSchema), func(v langext.MapEntry[int, schema.Def]) int { return v.Key }) {
h0, err := sq.HashGoSqliteSchema(ctx, schemaObj.Value.SQL)
if err != nil {
h0 = "ERR"
}
fmt.Printf("RequestsSchema%d := %s\n", i, h0)
fmt.Printf("RequestsSchema [%d] := %s%s\n", schemaObj.Key, h0, langext.Conditional(schemaObj.Key == schema.RequestsSchemaVersion, " (active)", ""))
}
for i := 1; i <= schema.LogsSchemaVersion; i++ {
h0, err := sq.HashGoSqliteSchema(ctx, schema.LogsSchema[i].SQL)
fmt.Printf("\n")
for _, schemaObj := range langext.AsSortedBy(langext.MapToArr(schema.LogsSchema), func(v langext.MapEntry[int, schema.Def]) int { return v.Key }) {
h0, err := sq.HashGoSqliteSchema(ctx, schemaObj.Value.SQL)
if err != nil {
h0 = "ERR"
}
fmt.Printf("LogsSchema%d := %s\n", i, h0)
fmt.Printf("LogsSchema [%d] := %s%s\n", schemaObj.Key, h0, langext.Conditional(schemaObj.Key == schema.LogsSchemaVersion, " (active)", ""))
}
fmt.Println()
fmt.Printf("\n")
}

View File

@@ -10,6 +10,7 @@ type DatabaseImpl interface {
Migrate(ctx context.Context) error
Ping(ctx context.Context) error
Version(ctx context.Context) (string, string, error)
BeginTx(ctx context.Context) (sq.Tx, error)
Stop(ctx context.Context) error

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