Compare commits

...

41 Commits

Author SHA1 Message Date
7bbe321d3c
Add confirm=? quer-param to delete-user route [skip-tests]
Some checks failed
Build Docker and Deploy / Run Unit-Tests (push) Has been skipped
Build Docker and Deploy / Build Docker Container (push) Failing after 41s
Build Docker and Deploy / Deploy to Server (push) Has been skipped
2025-05-04 04:57:42 +02:00
9db49a4164
improve delete-key flow
Some checks failed
Build Docker and Deploy / Build Docker Container (push) Failing after 1m15s
Build Docker and Deploy / Run Unit-Tests (push) Failing after 2m57s
Build Docker and Deploy / Deploy to Server (push) Has been skipped
2025-05-03 23:00:12 +02:00
1d2f4f70c8
Update goext|gognecht dependencies to new module-root 'git.blackforestbytes'
Some checks failed
Build Docker and Deploy / Build Docker Container (push) Has been cancelled
Build Docker and Deploy / Run Unit-Tests (push) Has been cancelled
Build Docker and Deploy / Deploy to Server (push) Has been cancelled
2025-05-03 16:59:57 +02:00
d1eecad059
Fix loglevel in ApplicationLog
Some checks failed
Build Docker and Deploy / Build Docker Container (push) Successful in 47s
Build Docker and Deploy / Run Unit-Tests (push) Failing after 11m12s
Build Docker and Deploy / Deploy to Server (push) Has been skipped
2025-04-26 14:18:51 +02:00
d5e9c6ecc3
improve dateFormat a bit, and fix some buttons
Some checks failed
Build Docker and Deploy / Build Docker Container (push) Successful in 49s
Build Docker and Deploy / Run Unit-Tests (push) Failing after 11m13s
Build Docker and Deploy / Deploy to Server (push) Has been skipped
2025-04-22 00:35:32 +02:00
b91ddc172d
Implement settings
Some checks failed
Build Docker and Deploy / Build Docker Container (push) Successful in 51s
Build Docker and Deploy / Run Unit-Tests (push) Failing after 11m17s
Build Docker and Deploy / Deploy to Server (push) Has been skipped
2025-04-19 02:02:37 +02:00
5417796f3f
do a few more remaining todos
Some checks failed
Build Docker and Deploy / Build Docker Container (push) Successful in 52s
Build Docker and Deploy / Run Unit-Tests (push) Failing after 11m24s
Build Docker and Deploy / Deploy to Server (push) Has been skipped
2025-04-18 19:14:36 +02:00
78c895547e
Finish KeyToken operations 2025-04-18 18:56:17 +02:00
1f0f280286
Fix ListChannels(owned) returning channels multiple (if there are deleted subscriptions) [skip-tests]
All checks were successful
Build Docker and Deploy / Run Unit-Tests (push) Has been skipped
Build Docker and Deploy / Build Docker Container (push) Successful in 41s
Build Docker and Deploy / Deploy to Server (push) Successful in 10s
2025-04-18 16:08:25 +02:00
b280465914
better error message 2025-04-18 14:07:31 +02:00
967ae915b2
add scrollbar 2025-04-18 13:15:33 +02:00
24cd1692c6
subscription list+view
Some checks failed
Build Docker and Deploy / Build Docker Container (push) Successful in 1m8s
Build Docker and Deploy / Run Unit-Tests (push) Failing after 11m19s
Build Docker and Deploy / Deploy to Server (push) Has been skipped
2025-04-18 01:45:56 +02:00
63bc71c405
Implement proper handling for inactive/active subscriptions 2025-04-18 00:11:01 +02:00
a43a3b441f
add qr button to channel_list_extended 2025-04-17 22:19:04 +02:00
ab4b40ab75
implement keytoken list and all-messages list
Some checks failed
Build Docker and Deploy / Build Docker Container (push) Successful in 50s
Build Docker and Deploy / Run Unit-Tests (push) Failing after 11m15s
Build Docker and Deploy / Deploy to Server (push) Has been skipped
2025-04-13 19:47:18 +02:00
e9c5c5fb99
Implement message filter scubscription_status and sender_user_id [skip-tests]
All checks were successful
Build Docker and Deploy / Run Unit-Tests (push) Has been skipped
Build Docker and Deploy / Build Docker Container (push) Successful in 45s
Build Docker and Deploy / Deploy to Server (push) Successful in 6s
2025-04-13 19:42:55 +02:00
b989a8359e
implement client_list 2025-04-13 18:02:20 +02:00
6ec1d80f49
finish sender_list && plain-text-search 2025-04-13 17:43:18 +02:00
c1e465020f
Implement sender_list 2025-04-13 17:21:12 +02:00
b687464d59
Fix display dateformat 2025-04-13 16:43:49 +02:00
3239a075fb
Implement user-deletion 2025-04-13 16:32:59 +02:00
8c0f0e3e8f
Add various deleted flags to entities | Add active to subscriptions | Add DeleteUser && DeleteChannel endpoints [skip-tests]
All checks were successful
Build Docker and Deploy / Run Unit-Tests (push) Has been skipped
Build Docker and Deploy / Build Docker Container (push) Successful in 43s
Build Docker and Deploy / Deploy to Server (push) Successful in 16s
2025-04-13 16:22:55 +02:00
aac34ef738
Create SendToken on login 2025-04-13 02:16:24 +02:00
e96be86314
Finish implementing send page 2025-04-13 01:52:29 +02:00
95353735b0
Implement Scanner-View 2025-04-13 00:17:06 +02:00
c0b8a8a3f4
Return subscription from channel-preview [skip-tests]
All checks were successful
Build Docker and Deploy / Run Unit-Tests (push) Has been skipped
Build Docker and Deploy / Build Docker Container (push) Successful in 46s
Build Docker and Deploy / Deploy to Server (push) Successful in 6s
2025-04-12 23:37:06 +02:00
301240b896
Allow querying key-tokens by token (including querying by id) [skip-tests]
All checks were successful
Build Docker and Deploy / Run Unit-Tests (push) Has been skipped
Build Docker and Deploy / Build Docker Container (push) Successful in 47s
Build Docker and Deploy / Deploy to Server (push) Successful in 6s
2025-04-12 22:19:41 +02:00
86b8c47ed5
Add option to skip tests [skip-tests]
All checks were successful
Build Docker and Deploy / Run Unit-Tests (push) Has been skipped
Build Docker and Deploy / Build Docker Container (push) Successful in 44s
Build Docker and Deploy / Deploy to Server (push) Successful in 12s
2025-04-12 21:43:49 +02:00
bc99f46720
Fix tests
Some checks failed
Build Docker and Deploy / Build Docker Container (push) Successful in 48s
Build Docker and Deploy / Run Unit-Tests (push) Failing after 9m44s
Build Docker and Deploy / Deploy to Server (push) Has been skipped
2025-04-12 21:09:55 +02:00
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
212 changed files with 10613 additions and 1694 deletions

View File

@ -3,6 +3,12 @@
# https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions
# https://docs.github.com/en/actions/learn-github-actions/contexts#github-context # https://docs.github.com/en/actions/learn-github-actions/contexts#github-context
# Configurable with a few commit messages:
# - [skip-tests] Skip the test stage
# - [skip-deployment] Skip the deployment stage
# - [skip-ci] Skip all stages (the whole ci/cd)
#
name: Build Docker and Deploy name: Build Docker and Deploy
run-name: Build & Deploy ${{ gitea.ref }} on ${{ gitea.actor }} run-name: Build & Deploy ${{ gitea.ref }} on ${{ gitea.actor }}
@ -16,6 +22,9 @@ jobs:
build_server: build_server:
name: Build Docker Container name: Build Docker Container
runs-on: bfb-cicd-latest runs-on: bfb-cicd-latest
if: >-
!contains(github.event.head_commit.message, '[skip-ci]') &&
!contains(github.event.head_commit.message, '[skip-deployment]')
steps: steps:
- run: echo -n "${{ secrets.DOCKER_REG_PASS }}" | docker login registry.blackforestbytes.com -u docker --password-stdin - run: echo -n "${{ secrets.DOCKER_REG_PASS }}" | docker login registry.blackforestbytes.com -u docker --password-stdin
- name: Check out code - name: Check out code
@ -27,6 +36,9 @@ jobs:
test_server: test_server:
name: Run Unit-Tests name: Run Unit-Tests
runs-on: bfb-cicd-latest runs-on: bfb-cicd-latest
if: >-
!contains(github.event.head_commit.message, '[skip-ci]') &&
!contains(github.event.head_commit.message, '[skip-tests]')
steps: steps:
- name: Check out code - name: Check out code
@ -68,6 +80,12 @@ jobs:
name: Deploy to Server name: Deploy to Server
needs: [build_server, test_server] needs: [build_server, test_server]
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: >-
!cancelled() &&
!contains(github.event.head_commit.message, '[skip-ci]') &&
!contains(github.event.head_commit.message, '[skip-deployment]') &&
needs.build_server.result == 'success' &&
(needs.test_server.result == 'skipped' || needs.test_server.result == 'success')
steps: steps:
- name: Execute deploy on remote (via ssh) - name: Execute deploy on remote (via ssh)
uses: appleboy/ssh-action@v1.0.0 uses: appleboy/ssh-action@v1.0.0

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
.aider*

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 targetCompatibility 1.8
sourceCompatibility 1.8 sourceCompatibility 1.8
} }
namespace 'com.blackforestbytes.simplecloudnotifier'
} }
dependencies { dependencies {

View File

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

View File

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

View File

@ -14,3 +14,6 @@ org.gradle.jvmargs=-Xmx1536m
android.useAndroidX=true android.useAndroidX=true
android.enableJetifier=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 distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists 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

4
flutter/.gitignore vendored
View File

@ -5,6 +5,8 @@
firepit-log.txt firepit-log.txt
flutter_jank_* flutter_jank_*
_releases/*
####################################################################################################################### #######################################################################################################################
@ -54,3 +56,5 @@ app.*.map.json
/android/app/debug /android/app/debug
/android/app/profile /android/app/profile
/android/app/release /android/app/release
/lib/git_stamp/

View File

@ -1,15 +1,40 @@
run: # Setup
flutter pub run build_runner build #
flutter run # flutter config --jdk-dir "/usr/lib/jvm/default-runtime/bin"
# sudo archlinux-java set java-17-openjdk
run-android: #
# runs app locally (linux)
run-linux: gen
dart run build_runner build
_JAVA_OPTIONS="" flutter run -d linux
# runs app locally (web | not really supported)
run-web: gen
dart run build_runner build
_JAVA_OPTIONS="" flutter run -d chrome
# runs on android device (must have network adb enabled teh correct IP)
run-android: gen
ping -c1 10.10.10.177 ping -c1 10.10.10.177
adb connect 10.10.10.177:5555 adb connect 10.10.10.177:5555
flutter pub run build_runner build flutter pub run build_runner build
flutter run -d 10.10.10.177:5555 _JAVA_OPTIONS="" flutter run -d 10.10.10.177:5555
install-release: gen
# Install on Pixel 7a
flutter build apk --release
flutter run --release -d 35221JEHN07157
build-release: gen
flutter build apk --release
flutter build appbundle --release
flutter build linux --release
test: test:
dart analyze dart analyze
@ -17,11 +42,30 @@ fix:
dart fix --apply dart fix --apply
gen: gen:
flutter pub run build_runner build ./_utils/inc_buildnum.sh
dart run build_runner build
dart run git_stamp git_stamp --build-type lite --limit 2
# run `make run` in another terminal (or another variant of flutter run)
autoreload: autoreload:
@# run `make run` in another terminal (or another variant of flutter run) @
@_utils/autoreload.sh @_utils/autoreload.sh
icons: icons:
flutter pub run flutter_launcher_icons -f "flutter_launcher_icons.yaml" 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
aider:
aider --model gemini-2.5-pro --no-auto-commits --no-dirty-commits --test-cmd "flutter build linux" --auto-test --subtree-only

View File

@ -1,30 +1,39 @@
# TODO # TODO
- [ ] Message List - [x] Message List
* [ ] CRUD * [x] CRUD
- [ ] Message Big-View - [x] Message Big-View
- [ ] Search/Filter Messages - [x] Search/Filter Messages
- [ ] Channel List - [x] Channel List
* [ ] Show subs * [x] Show subs
* [ ] CRUD * [x] CRUD
* [ ] what about unsubbed foreign channels? - thex should still be visible (or should they, do i still get the messages?) * [x] what about unsubbed foreign channels? - thex should still be visible (or should they, do i still get the messages?)
- [ ] Sub List - [x] Sub List
* [ ] Sub/Unsub/Accept/Deny * [x] Sub/Unsub/Accept/Deny
- [ ] Debug List (Show logs, requests) - [x] Debug List (Show logs, requests)
- [ ] Key List - [x] Key List
* [ ] CRUD * [x] CRUD
- [ ] Auto R-only key for admin, use for QR+link+send - [x] Auto R-only key for admin, use for QR+link+send
- [ ] settings - [ ] settings
- [ ] notifications - [?] notifications
- [ ] push navigation stack - [?] push navigation stack
- [ ] read + migrate old SharedPrefs (or not? - who uses SCN even??) - [/] read + migrate old SharedPrefs (or not? - who uses SCN even??)
- [ ] Account-Page - [x] Account-Page
- [ ] Logout - [x] Logout
- [ ] Send-page - [x] Send-page
- [ ] 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? - [ ] 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?
- [x] fix time format (in message-list, in card, top right) - midnight is shown as "24:05" instead of "00:05" - thats weird
- [x] Add scrollbar
-> https://api.flutter.dev/flutter/material/Scrollbar-class.html
- [x] 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 # TODO iOS specific

View File

@ -3,8 +3,8 @@
# shellcheck disable=SC2002 # disable useless-cat warning # shellcheck disable=SC2002 # disable useless-cat warning
set -o nounset # disallow usage of unset vars ( set -u ) set -o nounset # disallow usage of unset vars ( set -u )
set -o errexit # Exit immediately if a pipeline returns non-zero. ( set -e ) #set -o errexit # Exit immediately if a pipeline returns non-zero. ( set -e )
set -o errtrace # Allow the above trap be inherited by all functions in the script. ( set -E ) #set -o errtrace # Allow the above trap be inherited by all functions in the script. ( set -E )
set -o pipefail # Return value of a pipeline is the value of the last (rightmost) command to exit with a non-zero status set -o pipefail # Return value of a pipeline is the value of the last (rightmost) command to exit with a non-zero status
IFS=$'\n\t' # Set $IFS to only newline and tab. IFS=$'\n\t' # Set $IFS to only newline and tab.
@ -24,23 +24,34 @@
pid="$( pgrep -f 'flutter_tools\.[s]napshot run' || echo '' | tail -n 1 )" pids="$( pgrep -f 'flutter_tools\.[s]napshot run' || echo '' )"
if [ -z "$pid" ]; then if [ -z "$pids" ]; then
red "No [flutter run] process found - exiting" red "No [flutter run] process found - exiting"
exit 1 exit 1
fi fi
trap 'echo "reseived SIGNAL<EXIT> - exiting"; exit 0' EXIT trap 'echo "reseived SIGNAL<EXIT> - exiting"; jobs -p | xargs kill ; exit 0' EXIT
trap 'echo "reseived SIGNAL<SIGINT> - exiting"; exit 0' SIGINT trap 'echo "reseived SIGNAL<SIGINT> - exiting"; jobs -p | xargs kill ; exit 0' SIGINT
trap 'echo "reseived SIGNAL<SIGTERM> - exiting"; exit 0' SIGTERM trap 'echo "reseived SIGNAL<SIGTERM> - exiting"; jobs -p | xargs kill ; exit 0' SIGTERM
trap 'echo "reseived SIGNAL<SIGQUIT> - exiting"; exit 0' SIGQUIT trap 'echo "reseived SIGNAL<SIGQUIT> - exiting"; jobs -p | xargs kill ; exit 0' SIGQUIT
echo "" echo ""
blue "Listening for changes in lib/ directory - sending signals to ${pid}..." while IFS= read -r pid; do
blue "Listening for changes in lib/ directory - sending signals to ${pid}..."
done <<< "$pids"
echo "" echo ""
while true; do while IFS= read -r pid; do
find lib/ -name '*.dart' | entr -d -p sh -c "echo 'File(s) changed - Sending SIGUSR to $pid' ; kill -USR1 $pid"; {
yellow 'File list changed - restart'; while true; do
done find lib/ -name '*.dart' | entr -d -p sh -c "echo 'File(s) changed - Sending SIGUSR to $pid' ; kill -USR1 $pid";
yellow 'File list changed - restart';
done
} &
done <<< "$pids"
wait # wait for all background jobs to finish
echo "DONE."

41
flutter/_utils/inc_buildnum.sh Executable file
View File

@ -0,0 +1,41 @@
#!/bin/bash
# shellcheck disable=SC2002 # disable useless-cat warning
set -o nounset # disallow usage of unset vars ( set -u )
set -o errexit # Exit immediately if a pipeline returns non-zero. ( set -e )
set -o errtrace # Allow the above trap be inherited by all functions in the script. ( set -E )
set -o pipefail # Return value of a pipeline is the value of the last (rightmost) command to exit with a non-zero status
IFS=$'\n\t' # Set $IFS to only newline and tab.
# shellcheck disable=SC2034
cr=$'\n'
function black() { if [ -t 1 ] && [ -n "$(tput colors)" ] && [ "$(tput colors)" -ge 8 ]; then echo -e "\\x1B[30m$1\\x1B[0m"; else echo "$1"; fi }
function red() { if [ -t 1 ] && [ -n "$(tput colors)" ] && [ "$(tput colors)" -ge 8 ]; then echo -e "\\x1B[31m$1\\x1B[0m"; else echo "$1"; fi; }
function green() { if [ -t 1 ] && [ -n "$(tput colors)" ] && [ "$(tput colors)" -ge 8 ]; then echo -e "\\x1B[32m$1\\x1B[0m"; else echo "$1"; fi; }
function yellow(){ if [ -t 1 ] && [ -n "$(tput colors)" ] && [ "$(tput colors)" -ge 8 ]; then echo -e "\\x1B[33m$1\\x1B[0m"; else echo "$1"; fi; }
function blue() { if [ -t 1 ] && [ -n "$(tput colors)" ] && [ "$(tput colors)" -ge 8 ]; then echo -e "\\x1B[34m$1\\x1B[0m"; else echo "$1"; fi; }
function purple(){ if [ -t 1 ] && [ -n "$(tput colors)" ] && [ "$(tput colors)" -ge 8 ]; then echo -e "\\x1B[35m$1\\x1B[0m"; else echo "$1"; fi; }
function cyan() { if [ -t 1 ] && [ -n "$(tput colors)" ] && [ "$(tput colors)" -ge 8 ]; then echo -e "\\x1B[36m$1\\x1B[0m"; else echo "$1"; fi; }
function white() { if [ -t 1 ] && [ -n "$(tput colors)" ] && [ "$(tput colors)" -ge 8 ]; then echo -e "\\x1B[37m$1\\x1B[0m"; else echo "$1"; fi; }
path_to_pubspec="$(dirname "$0")/../pubspec.yaml"
current_version=$(awk '/^version:/ {print $2}' $path_to_pubspec)
current_version_without_build=$(echo "$current_version" | sed 's/\+.*//')
gitcount="$(git log | grep "^commit" | wc -l | xargs)"
new_version="$current_version_without_build+$gitcount"
echo "Setting pubspec.yaml version $current_version to $new_version"
if [[ "$OSTYPE" == "darwin"* ]]; then
# macOS sed (requires a space after -i)
sed -i '' -e "s/version: $current_version/version: $new_version/g" $path_to_pubspec
else
# GNU sed (requires no space after -i)
sed -i'' -e "s/version: $current_version/version: $new_version/g" $path_to_pubspec
fi

View File

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

View File

@ -34,16 +34,16 @@ if (keystorePropertiesFile.exists()) {
android { android {
namespace "com.blackforestbytes.simplecloudnotifier" namespace "com.blackforestbytes.simplecloudnotifier"
compileSdkVersion flutter.compileSdkVersion 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 { compileOptions {
coreLibraryDesugaringEnabled true coreLibraryDesugaringEnabled true
sourceCompatibility JavaVersion.VERSION_1_8 sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_17
} }
kotlinOptions { kotlinOptions {
jvmTarget = '1.8' jvmTarget = '17'
} }
sourceSets { sourceSets {

View File

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

View File

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

View File

@ -23,7 +23,8 @@ pluginManagement {
plugins { plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0" 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 // START: FlutterFire Configuration
id "com.google.gms.google-services" version "4.3.15" apply false id "com.google.gms.google-services" version "4.3.15" apply false
// END: FlutterFire Configuration // END: FlutterFire Configuration

View File

@ -45,5 +45,10 @@
<true/> <true/>
<key>UIApplicationSupportsIndirectInputEvents</key> <key>UIApplicationSupportsIndirectInputEvents</key>
<true/> <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> </dict>
</plist> </plist>

View File

@ -5,6 +5,8 @@ import 'package:simplecloudnotifier/api/api_exception.dart';
import 'package:simplecloudnotifier/models/api_error.dart'; import 'package:simplecloudnotifier/models/api_error.dart';
import 'package:simplecloudnotifier/models/client.dart'; import 'package:simplecloudnotifier/models/client.dart';
import 'package:simplecloudnotifier/models/keytoken.dart'; import 'package:simplecloudnotifier/models/keytoken.dart';
import 'package:simplecloudnotifier/models/send_message_response.dart';
import 'package:simplecloudnotifier/models/sender_name_statistics.dart';
import 'package:simplecloudnotifier/models/subscription.dart'; import 'package:simplecloudnotifier/models/subscription.dart';
import 'package:simplecloudnotifier/models/user.dart'; import 'package:simplecloudnotifier/models/user.dart';
import 'package:simplecloudnotifier/state/app_auth.dart'; import 'package:simplecloudnotifier/state/app_auth.dart';
@ -30,26 +32,31 @@ enum ChannelSelector {
class MessageFilter { class MessageFilter {
List<String>? channelIDs; List<String>? channelIDs;
List<String>? searchFilter; List<String>? searchFilter;
List<String>? plainSearchFilter;
List<String>? senderNames; List<String>? senderNames;
List<String>? usedKeys; List<String>? usedKeys;
List<int>? priority; List<int>? priority;
DateTime? timeBefore; DateTime? timeBefore;
DateTime? timeAfter; DateTime? timeAfter;
bool? hasSenderName; bool? hasSenderName;
List<String>? senderUserID;
MessageFilter({ MessageFilter({
this.channelIDs, this.channelIDs,
this.searchFilter, this.searchFilter,
this.plainSearchFilter,
this.senderNames, this.senderNames,
this.usedKeys, this.usedKeys,
this.priority, this.priority,
this.timeBefore, this.timeBefore,
this.timeAfter, this.timeAfter,
this.senderUserID,
}); });
} }
class APIClient { class APIClient {
static const String _base = 'https://simplecloudnotifier.de/api/v2'; static const String _base = 'https://simplecloudnotifier.de';
static const String _prefix = '/api/v2';
static Future<T> _request<T>({ static Future<T> _request<T>({
required String name, required String name,
@ -60,10 +67,11 @@ class APIClient {
dynamic jsonBody, dynamic jsonBody,
String? authToken, String? authToken,
Map<String, String>? header, Map<String, String>? header,
bool? nonAPI,
}) async { }) async {
final t0 = DateTime.now(); final t0 = DateTime.now();
final uri = Uri.parse('$_base/$relURL').replace(queryParameters: query ?? {}); final uri = Uri.parse('$_base${(nonAPI ?? false) ? '' : _prefix}/$relURL').replace(queryParameters: query ?? {});
final req = http.Request(method, uri); final req = http.Request(method, uri);
@ -101,19 +109,21 @@ class APIClient {
} }
if (responseStatusCode != 200) { if (responseStatusCode != 200) {
try { APIError apierr;
final apierr = APIError.fromJson(jsonDecode(responseBody) as Map<String, dynamic>);
RequestLog.addRequestAPIError(name, t0, method, uri, req.body, req.headers, responseStatusCode, responseBody, responseHeaders, apierr); try {
Toaster.error("Error", 'Request "${name}" failed'); apierr = APIError.fromJson(jsonDecode(responseBody) as Map<String, dynamic>);
throw APIException(responseStatusCode, apierr.error, apierr.errhighlight, apierr.message);
} catch (exc, trace) { } catch (exc, trace) {
ApplicationLog.warn('Failed to decode api response as error-object', additional: exc.toString() + "\nBody:\n" + responseBody, trace: 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.addRequestErrorStatuscode(name, t0, method, uri, req.body, req.headers, responseStatusCode, responseBody, responseHeaders); RequestLog.addRequestAPIError(name, t0, method, uri, req.body, req.headers, responseStatusCode, responseBody, responseHeaders, apierr);
Toaster.error("Error", 'Request "${name}" failed'); Toaster.error("Error", apierr.message);
throw Exception('API request failed with status code ${responseStatusCode}'); throw APIException(responseStatusCode, apierr.error, apierr.errhighlight, apierr.message);
} }
try { try {
@ -159,6 +169,30 @@ 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<User> deleteUser(TokenSource auth, String uid) async {
return await _request(
name: 'deleteUser',
method: 'DELETE',
relURL: 'users/$uid',
fn: User.fromJson,
authToken: auth.getToken(),
);
}
static Future<Client> addClient(TokenSource auth, String fcmToken, String agentModel, String agentVersion, String? name, String clientType) async { static Future<Client> addClient(TokenSource auth, String fcmToken, String agentModel, String agentVersion, String? name, String clientType) async {
return await _request( return await _request(
name: 'addClient', name: 'addClient',
@ -176,16 +210,16 @@ class APIClient {
); );
} }
static Future<Client> updateClient(TokenSource auth, String clientID, String fcmToken, String agentModel, String? name, String agentVersion) async { static Future<Client> updateClient(TokenSource auth, String clientID, {String? fcmToken, String? agentModel, String? name, String? agentVersion}) async {
return await _request( return await _request(
name: 'updateClient', name: 'updateClient',
method: 'PUT', method: 'PUT',
relURL: 'users/${auth.getUserID()}/clients/$clientID', relURL: 'users/${auth.getUserID()}/clients/$clientID',
jsonBody: { jsonBody: {
'fcm_token': fcmToken, if (fcmToken != null) 'fcm_token': fcmToken,
'agent_model': agentModel, if (agentModel != null) 'agent_model': agentModel,
'agent_version': agentVersion, if (agentVersion != null) 'agent_version': agentVersion,
'name': name, if (name != null) 'name': name,
}, },
fn: Client.fromJson, fn: Client.fromJson,
authToken: auth.getToken(), authToken: auth.getToken(),
@ -249,7 +283,7 @@ class APIClient {
); );
} }
static Future<(String, List<SCNMessage>)> getMessageList(TokenSource auth, String pageToken, {int? pageSize, MessageFilter? filter}) async { static Future<(String, List<SCNMessage>)> getMessageList(TokenSource auth, String pageToken, {int? pageSize, MessageFilter? filter, bool? includeNonSuscribed}) async {
return await _request( return await _request(
name: 'getMessageList', name: 'getMessageList',
method: 'GET', method: 'GET',
@ -258,6 +292,7 @@ class APIClient {
'next_page_token': [pageToken], 'next_page_token': [pageToken],
if (pageSize != null) 'page_size': [pageSize.toString()], if (pageSize != null) 'page_size': [pageSize.toString()],
if (filter?.searchFilter != null) 'search': filter!.searchFilter!, if (filter?.searchFilter != null) 'search': filter!.searchFilter!,
if (filter?.plainSearchFilter != null) 'string_search': filter!.plainSearchFilter!,
if (filter?.channelIDs != null) 'channel_id': filter!.channelIDs!, if (filter?.channelIDs != null) 'channel_id': filter!.channelIDs!,
if (filter?.senderNames != null) 'sender': filter!.senderNames!, if (filter?.senderNames != null) 'sender': filter!.senderNames!,
if (filter?.hasSenderName != null) 'has_sender': [filter!.hasSenderName!.toString()], if (filter?.hasSenderName != null) 'has_sender': [filter!.hasSenderName!.toString()],
@ -265,6 +300,8 @@ class APIClient {
if (filter?.timeAfter != null) 'after': [filter!.timeAfter!.toIso8601String()], if (filter?.timeAfter != null) 'after': [filter!.timeAfter!.toIso8601String()],
if (filter?.priority != null) 'priority': filter!.priority!.map((p) => p.toString()).toList(), if (filter?.priority != null) 'priority': filter!.priority!.map((p) => p.toString()).toList(),
if (filter?.usedKeys != null) 'used_key': filter!.usedKeys!, if (filter?.usedKeys != null) 'used_key': filter!.usedKeys!,
if (filter?.senderUserID != null) 'sender_user_id': filter!.senderUserID!,
if (includeNonSuscribed ?? false) 'subscription_status': ['all'],
}, },
fn: (json) => SCNMessage.fromPaginatedJsonArray(json, 'messages', 'next_page_token'), fn: (json) => SCNMessage.fromPaginatedJsonArray(json, 'messages', 'next_page_token'),
authToken: auth.getToken(), authToken: auth.getToken(),
@ -281,11 +318,40 @@ class APIClient {
); );
} }
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(),
);
}
static Future<Subscription> getSubscription(TokenSource auth, String subscriptionID) async {
return await _request(
name: 'getSubscription',
method: 'GET',
relURL: 'users/${auth.getUserID()}/subscriptions/${subscriptionID}',
fn: Subscription.fromJson,
authToken: auth.getToken(),
);
}
static Future<List<Subscription>> getSubscriptionList(TokenSource auth) async { static Future<List<Subscription>> getSubscriptionList(TokenSource auth) async {
return await _request( return await _request(
name: 'getSubscriptionList', name: 'getSubscriptionList',
method: 'GET', method: 'GET',
relURL: 'users/${auth.getUserID()}/subscriptions', relURL: 'users/${auth.getUserID()}/subscriptions',
query: {
'direction': ['both'],
'confirmation': ['all'],
'external': ['all'],
},
fn: (json) => Subscription.fromJsonArray(json['subscriptions'] as List<dynamic>), fn: (json) => Subscription.fromJsonArray(json['subscriptions'] as List<dynamic>),
authToken: auth.getToken(), authToken: auth.getToken(),
); );
@ -349,9 +415,9 @@ class APIClient {
); );
} }
static Future<KeyTokenPreview> getKeyTokenPreview(TokenSource auth, String kid) async { static Future<KeyTokenPreview> getKeyTokenPreviewByID(TokenSource auth, String kid) async {
return await _request( return await _request(
name: 'getKeyTokenPreview', name: 'getKeyTokenPreviewByID',
method: 'GET', method: 'GET',
relURL: 'preview/keys/$kid', relURL: 'preview/keys/$kid',
fn: KeyTokenPreview.fromJson, fn: KeyTokenPreview.fromJson,
@ -359,6 +425,16 @@ class APIClient {
); );
} }
static Future<KeyTokenPreview> getKeyTokenPreviewByToken(TokenSource auth, String tok) async {
return await _request(
name: 'getKeyTokenPreviewByToken',
method: 'GET',
relURL: 'preview/keys/$tok',
fn: KeyTokenPreview.fromJson,
authToken: auth.getToken(),
);
}
static Future<KeyToken> getKeyTokenByToken(String userid, String token) async { static Future<KeyToken> getKeyTokenByToken(String userid, String token) async {
return await _request( return await _request(
name: 'getCurrentKeyToken', name: 'getCurrentKeyToken',
@ -369,7 +445,155 @@ class APIClient {
); );
} }
static Future<List<String>> getSenderNameList(AppAuth userAcc) { static Future<void> deleteKeyToken(AppAuth acc, String keytokenID) {
return Future.value(['TODO']); //TODO return _request(
name: 'deleteKeyToken',
method: 'DELETE',
relURL: 'users/${acc.getUserID()}/keys/${keytokenID}',
fn: (_) => null,
authToken: acc.getToken(),
);
}
static Future<KeyToken> updateKeyToken(TokenSource auth, String kid, {String? name, bool? allChannels, List<String>? channels, String? permissions}) async {
return await _request(
name: 'updateKeyToken',
method: 'PATCH',
relURL: 'users/${auth.getUserID()}/keys/${kid}',
jsonBody: {
if (name != null) 'name': name,
if (allChannels != null) 'all_channels': allChannels,
if (channels != null) 'channels': channels,
if (permissions != null) 'permissions': permissions,
},
fn: KeyToken.fromJson,
authToken: auth.getToken(),
);
}
static Future<KeyTokenWithToken> createKeyToken(TokenSource auth, String name, String perm, bool allChannels, {List<String>? channels}) async {
return await _request(
name: 'createKeyToken',
method: 'POST',
relURL: 'users/${auth.getUserID()}/keys',
jsonBody: {
'name': name,
'permissions': perm,
'all_channels': allChannels,
if (channels != null) 'channels': channels,
},
fn: KeyTokenWithToken.fromJson,
authToken: auth.getToken(),
);
}
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, {String? subscribeKey}) async {
return await _request(
name: 'subscribeToChannelbyID',
method: 'POST',
relURL: 'users/${auth.getUserID()}/subscriptions',
query: {
if (subscribeKey != null) 'chan_subscribe_key': [subscribeKey],
},
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(),
);
}
static Future<Subscription> activateSubscription(TokenSource auth, String channelID, String subID) async {
return await _request(
name: 'activateSubscription',
method: 'PATCH',
relURL: 'users/${auth.getUserID()}/subscriptions/${subID}',
jsonBody: {
'active': true,
},
fn: Subscription.fromJson,
authToken: auth.getToken(),
);
}
static Future<Subscription> deactivateSubscription(TokenSource auth, String channelID, String subID) async {
return await _request(
name: 'deactivateSubscription',
method: 'PATCH',
relURL: 'users/${auth.getUserID()}/subscriptions/${subID}',
jsonBody: {
'active': false,
},
fn: Subscription.fromJson,
authToken: auth.getToken(),
);
}
static Future<SendMessageResponse> sendMessage(String userid, String keytoken, String text, {String? channel, String? content, String? messageID, int? priority, String? senderName, DateTime? timestamp}) async {
return await _request(
name: 'sendMessage',
method: 'POST',
relURL: '/send',
nonAPI: true,
jsonBody: {
'user_id': userid,
'key': keytoken,
'title': text,
if (channel != null) 'channel': channel,
if (content != null) 'content': content,
if (priority != null) 'priority': priority,
if (messageID != null) 'msg_id': messageID,
if (timestamp != null) 'timestamp': (timestamp.microsecondsSinceEpoch / 1000).toInt(),
if (senderName != null) 'sender_name': senderName,
},
fn: SendMessageResponse.fromJson,
authToken: null,
);
} }
} }

View File

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

View File

@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
enum BadgeMode { error, warn, info }
class BadgeDisplay extends StatelessWidget {
final String text;
final BadgeMode mode;
final IconData? icon;
const BadgeDisplay({
Key? key,
required this.text,
required this.mode,
required this.icon,
}) : super(key: key);
@override
Widget build(BuildContext context) {
var col = Colors.grey;
var colFG = Colors.black;
if (mode == BadgeMode.error) col = Colors.red;
if (mode == BadgeMode.warn) col = Colors.orange;
if (mode == BadgeMode.info) col = Colors.blue;
if (mode == BadgeMode.error) colFG = Colors.red[900]!;
if (mode == BadgeMode.warn) colFG = Colors.black;
if (mode == BadgeMode.info) colFG = Colors.black;
return Container(
padding: const EdgeInsets.fromLTRB(8, 2, 8, 2),
decoration: BoxDecoration(
color: col[100],
border: Border.all(color: col[300]!),
borderRadius: BorderRadius.circular(4.0),
),
child: Row(
children: [
if (icon != null) Icon(icon!, color: colFG, size: 16.0),
Expanded(
child: Text(
text,
textAlign: TextAlign.center,
style: TextStyle(color: colFG, fontSize: 14.0),
),
),
],
),
);
}
}

View File

@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
class ErrorDisplay extends StatelessWidget {
final String errorMessage;
const ErrorDisplay({Key? key, required this.errorMessage}) : super(key: key);
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Container(
constraints: const BoxConstraints(maxWidth: 300),
padding: const EdgeInsets.all(16.0),
decoration: BoxDecoration(
color: Colors.red[100],
border: Border.all(color: Colors.red[300]!),
borderRadius: BorderRadius.circular(12.0),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(FontAwesomeIcons.triangleExclamation, color: Colors.red, size: 48.0),
const SizedBox(height: 16.0),
Text(
errorMessage,
textAlign: TextAlign.center,
style: TextStyle(color: Colors.red[900], fontSize: 16.0),
),
],
),
),
),
);
}
}

View File

@ -5,7 +5,7 @@ import 'package:simplecloudnotifier/components/layout/app_bar_filter_dialog.dart
import 'package:simplecloudnotifier/components/layout/app_bar_progress_indicator.dart'; import 'package:simplecloudnotifier/components/layout/app_bar_progress_indicator.dart';
import 'package:simplecloudnotifier/pages/debug/debug_main.dart'; import 'package:simplecloudnotifier/pages/debug/debug_main.dart';
import 'package:simplecloudnotifier/pages/message_list/message_filter_chiplet.dart'; import 'package:simplecloudnotifier/pages/message_list/message_filter_chiplet.dart';
import 'package:simplecloudnotifier/settings/app_settings.dart'; import 'package:simplecloudnotifier/state/app_settings.dart';
import 'package:simplecloudnotifier/state/app_bar_state.dart'; import 'package:simplecloudnotifier/state/app_bar_state.dart';
import 'package:simplecloudnotifier/state/app_events.dart'; import 'package:simplecloudnotifier/state/app_events.dart';
import 'package:simplecloudnotifier/state/app_theme.dart'; import 'package:simplecloudnotifier/state/app_theme.dart';
@ -64,7 +64,7 @@ class _SCNAppBarState extends State<SCNAppBar> {
builder: (context, appTheme, child) => IconButton( builder: (context, appTheme, child) => IconButton(
icon: Icon(appTheme.darkMode ? FontAwesomeIcons.solidSun : FontAwesomeIcons.solidMoon), icon: Icon(appTheme.darkMode ? FontAwesomeIcons.solidSun : FontAwesomeIcons.solidMoon),
tooltip: appTheme.darkMode ? 'Light mode' : 'Dark mode', tooltip: appTheme.darkMode ? 'Light mode' : 'Dark mode',
onPressed: appTheme.switchDarkMode, onPressed: AppTheme().switchDarkMode,
), ),
)); ));
} else { } else {

View File

@ -3,6 +3,7 @@ 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_channel.dart';
import 'package:simplecloudnotifier/components/modals/filter_modal_keytoken.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_priority.dart';
import 'package:simplecloudnotifier/components/modals/filter_modal_searchplain.dart';
import 'package:simplecloudnotifier/components/modals/filter_modal_sendername.dart'; import 'package:simplecloudnotifier/components/modals/filter_modal_sendername.dart';
import 'package:simplecloudnotifier/components/modals/filter_modal_time.dart'; import 'package:simplecloudnotifier/components/modals/filter_modal_time.dart';
import 'package:simplecloudnotifier/state/app_bar_state.dart'; import 'package:simplecloudnotifier/state/app_bar_state.dart';
@ -16,7 +17,9 @@ class AppBarFilterDialog extends StatefulWidget {
class _AppBarFilterDialogState extends State<AppBarFilterDialog> { class _AppBarFilterDialogState extends State<AppBarFilterDialog> {
double _height = 0; double _height = 0;
double _targetHeight = 4 + (48 * 6) + (16 * 5) + 4; static const int _itemCount = 7;
static const double _targetHeight = 4 + (48 * _itemCount) + (16 * (_itemCount - 1)) + 4;
@override @override
void initState() { void initState() {
@ -117,6 +120,6 @@ class _AppBarFilterDialogState extends State<AppBarFilterDialog> {
} }
void _showPlainSearchModal(BuildContext context) { void _showPlainSearchModal(BuildContext context) {
//TODO showDialog<void>(context: context, builder: (BuildContext context) => FilterModalSearchPlain()); showDialog<void>(context: context, builder: (BuildContext context) => FilterModalSearchPlain());
} }
} }

View File

@ -10,9 +10,11 @@ class SCNScaffold extends StatelessWidget {
this.showSearch = true, this.showSearch = true,
this.showShare = false, this.showShare = false,
this.onShare = null, this.onShare = null,
this.floatingActionButton = null,
}) : super(key: key); }) : super(key: key);
final Widget child; final Widget child;
final Widget? floatingActionButton;
final String? title; final String? title;
final bool showThemeSwitch; final bool showThemeSwitch;
final bool showSearch; final bool showSearch;
@ -30,6 +32,7 @@ class SCNScaffold extends StatelessWidget {
onShare: onShare ?? () {}, onShare: onShare ?? () {},
), ),
body: child, body: child,
floatingActionButton: floatingActionButton,
); );
} }
} }

View File

@ -1,11 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:simplecloudnotifier/api/api_client.dart'; import 'package:simplecloudnotifier/api/api_client.dart';
import 'package:simplecloudnotifier/components/error_display/error_display.dart';
import 'package:simplecloudnotifier/models/channel.dart'; import 'package:simplecloudnotifier/models/channel.dart';
import 'package:simplecloudnotifier/pages/message_list/message_filter_chiplet.dart'; import 'package:simplecloudnotifier/pages/message_list/message_filter_chiplet.dart';
import 'package:simplecloudnotifier/state/app_auth.dart'; import 'package:simplecloudnotifier/state/app_auth.dart';
import 'package:simplecloudnotifier/state/app_events.dart'; import 'package:simplecloudnotifier/state/app_events.dart';
import 'package:simplecloudnotifier/types/immediate_future.dart'; import 'package:simplecloudnotifier/types/immediate_future.dart';
import 'package:simplecloudnotifier/utils/navi.dart';
class FilterModalChannel extends StatefulWidget { class FilterModalChannel extends StatefulWidget {
@override @override
@ -15,13 +17,12 @@ class FilterModalChannel extends StatefulWidget {
class _FilterModalChannelState extends State<FilterModalChannel> { class _FilterModalChannelState extends State<FilterModalChannel> {
Set<String> _selectedEntries = {}; Set<String> _selectedEntries = {};
late ImmediateFuture<List<Channel>>? _futureChannels; ImmediateFuture<List<Channel>> _futureChannels = ImmediateFuture.ofPending();
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_futureChannels = null;
_futureChannels = ImmediateFuture.ofFuture(() async { _futureChannels = ImmediateFuture.ofFuture(() async {
final userAcc = Provider.of<AppAuth>(context, listen: false); final userAcc = Provider.of<AppAuth>(context, listen: false);
if (!userAcc.isAuth()) throw new Exception('not logged in'); if (!userAcc.isAuth()) throw new Exception('not logged in');
@ -49,45 +50,39 @@ class _FilterModalChannelState extends State<FilterModalChannel> {
content: Container( content: Container(
width: 9000, width: 9000,
height: 9000, height: 9000,
child: () { child: FutureBuilder(
if (_futureChannels == null) { future: _futureChannels.future,
return Center(child: CircularProgressIndicator()); builder: ((context, snapshot) {
} if (_futureChannels.value != null) {
return _buildList(context, _futureChannels.value!);
return FutureBuilder( } else if (snapshot.connectionState == ConnectionState.waiting) {
future: _futureChannels!.future, return Center(child: CircularProgressIndicator());
builder: ((context, snapshot) { } else if (snapshot.connectionState == ConnectionState.done && snapshot.hasError) {
if (_futureChannels?.value != null) { return ErrorDisplay(errorMessage: '${snapshot.error}');
return _buildList(context, _futureChannels!.value!); } else if (snapshot.connectionState == ConnectionState.done && snapshot.hasData) {
} else if (snapshot.connectionState == ConnectionState.done && snapshot.hasError) { return _buildList(context, snapshot.data!);
return Text('Error: ${snapshot.error}'); //TODO better error display } else {
} else if (snapshot.connectionState == ConnectionState.done) { return ErrorDisplay(errorMessage: 'Invalid future state');
return _buildList(context, snapshot.data!); }
} else { }),
return Center(child: CircularProgressIndicator()); ),
}
}),
);
}(),
), ),
actions: <Widget>[ actions: <Widget>[
TextButton( TextButton(
style: TextButton.styleFrom(textStyle: Theme.of(context).textTheme.labelLarge), style: TextButton.styleFrom(textStyle: Theme.of(context).textTheme.labelLarge),
child: const Text('Apply'), child: const Text('Apply'),
onPressed: () { onPressed: _onOkay,
onOkay();
},
), ),
], ],
); );
} }
void onOkay() { void _onOkay() {
Navigator.of(context).pop(); Navi.popDialog(context);
final chiplets = _selectedEntries final chiplets = _selectedEntries
.map((e) => MessageFilterChiplet( .map((e) => MessageFilterChiplet(
label: _futureChannels?.get()?.map((e) => e as Channel?).firstWhere((p) => p?.channelID == e, orElse: () => null)?.displayName ?? '???', label: _futureChannels.get()?.map((e) => e as Channel?).firstWhere((p) => p?.channelID == e, orElse: () => null)?.displayName ?? '???',
value: e, value: e,
type: MessageFilterChipletType.channel, type: MessageFilterChipletType.channel,
)) ))

View File

@ -1,11 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:simplecloudnotifier/api/api_client.dart'; import 'package:simplecloudnotifier/api/api_client.dart';
import 'package:simplecloudnotifier/components/error_display/error_display.dart';
import 'package:simplecloudnotifier/models/keytoken.dart'; import 'package:simplecloudnotifier/models/keytoken.dart';
import 'package:simplecloudnotifier/pages/message_list/message_filter_chiplet.dart'; import 'package:simplecloudnotifier/pages/message_list/message_filter_chiplet.dart';
import 'package:simplecloudnotifier/state/app_auth.dart'; import 'package:simplecloudnotifier/state/app_auth.dart';
import 'package:simplecloudnotifier/state/app_events.dart'; import 'package:simplecloudnotifier/state/app_events.dart';
import 'package:simplecloudnotifier/types/immediate_future.dart'; import 'package:simplecloudnotifier/types/immediate_future.dart';
import 'package:simplecloudnotifier/utils/navi.dart';
class FilterModalKeytoken extends StatefulWidget { class FilterModalKeytoken extends StatefulWidget {
@override @override
@ -15,13 +17,12 @@ class FilterModalKeytoken extends StatefulWidget {
class _FilterModalKeytokenState extends State<FilterModalKeytoken> { class _FilterModalKeytokenState extends State<FilterModalKeytoken> {
Set<String> _selectedEntries = {}; Set<String> _selectedEntries = {};
late ImmediateFuture<List<KeyToken>>? _futureKeyTokens; ImmediateFuture<List<KeyToken>> _futureKeyTokens = ImmediateFuture.ofPending();
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_futureKeyTokens = null;
_futureKeyTokens = ImmediateFuture.ofFuture(() async { _futureKeyTokens = ImmediateFuture.ofFuture(() async {
final userAcc = Provider.of<AppAuth>(context, listen: false); final userAcc = Provider.of<AppAuth>(context, listen: false);
if (!userAcc.isAuth()) throw new Exception('not logged in'); if (!userAcc.isAuth()) throw new Exception('not logged in');
@ -49,26 +50,22 @@ class _FilterModalKeytokenState extends State<FilterModalKeytoken> {
content: Container( content: Container(
width: 9000, width: 9000,
height: 9000, height: 9000,
child: () { child: FutureBuilder(
if (_futureKeyTokens == null) { future: _futureKeyTokens.future,
return Center(child: CircularProgressIndicator()); builder: ((context, snapshot) {
} if (_futureKeyTokens.value != null) {
return _buildList(context, _futureKeyTokens.value!);
return FutureBuilder( } else if (snapshot.connectionState == ConnectionState.waiting) {
future: _futureKeyTokens!.future, return Center(child: CircularProgressIndicator());
builder: ((context, snapshot) { } else if (snapshot.connectionState == ConnectionState.done && snapshot.hasError) {
if (_futureKeyTokens?.value != null) { return ErrorDisplay(errorMessage: '${snapshot.error}');
return _buildList(context, _futureKeyTokens!.value!); } else if (snapshot.connectionState == ConnectionState.done && snapshot.hasData) {
} else if (snapshot.connectionState == ConnectionState.done && snapshot.hasError) { return _buildList(context, snapshot.data!);
return Text('Error: ${snapshot.error}'); //TODO better error display } else {
} else if (snapshot.connectionState == ConnectionState.done) { return ErrorDisplay(errorMessage: 'Invalid future state');
return _buildList(context, snapshot.data!); }
} else { }),
return Center(child: CircularProgressIndicator()); ),
}
}),
);
}(),
), ),
actions: <Widget>[ actions: <Widget>[
TextButton( TextButton(
@ -83,11 +80,11 @@ class _FilterModalKeytokenState extends State<FilterModalKeytoken> {
} }
void onOkay() { void onOkay() {
Navigator.of(context).pop(); Navi.popDialog(context);
final chiplets = _selectedEntries final chiplets = _selectedEntries
.map((e) => MessageFilterChiplet( .map((e) => MessageFilterChiplet(
label: _futureKeyTokens?.get()?.map((e) => e as KeyToken?).firstWhere((p) => p?.keytokenID == e, orElse: () => null)?.name ?? '???', label: _futureKeyTokens.get()?.map((e) => e as KeyToken?).firstWhere((p) => p?.keytokenID == e, orElse: () => null)?.name ?? '???',
value: e, value: e,
type: MessageFilterChipletType.sender, type: MessageFilterChipletType.sender,
)) ))

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:simplecloudnotifier/pages/message_list/message_filter_chiplet.dart'; import 'package:simplecloudnotifier/pages/message_list/message_filter_chiplet.dart';
import 'package:simplecloudnotifier/state/app_events.dart'; import 'package:simplecloudnotifier/state/app_events.dart';
import 'package:simplecloudnotifier/utils/navi.dart';
class FilterModalPriority extends StatefulWidget { class FilterModalPriority extends StatefulWidget {
@override @override
@ -58,7 +59,7 @@ class _FilterModalPriorityState extends State<FilterModalPriority> {
} }
void onOkay() { void onOkay() {
Navigator.of(context).pop(); Navi.popDialog(context);
final chiplets = _selectedEntries.map((e) => MessageFilterChiplet(label: _texts[e]?.$2 ?? '???', value: e, type: MessageFilterChipletType.priority)).toList(); final chiplets = _selectedEntries.map((e) => MessageFilterChiplet(label: _texts[e]?.$2 ?? '???', value: e, type: MessageFilterChipletType.priority)).toList();

View File

@ -0,0 +1,61 @@
import 'package:flutter/material.dart';
import 'package:simplecloudnotifier/pages/message_list/message_filter_chiplet.dart';
import 'package:simplecloudnotifier/state/app_events.dart';
import 'package:simplecloudnotifier/utils/navi.dart';
class FilterModalSearchPlain extends StatefulWidget {
@override
_FilterModalSearchPlainState createState() => _FilterModalSearchPlainState();
}
class _FilterModalSearchPlainState extends State<FilterModalSearchPlain> {
final _controller = TextEditingController();
@override
void initState() {
super.initState();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Search'),
content: Container(
child: TextField(
autofocus: true,
controller: _controller,
decoration: InputDecoration(hintText: "Search..."),
),
),
actions: <Widget>[
TextButton(
style: TextButton.styleFrom(textStyle: Theme.of(context).textTheme.labelLarge),
child: const Text('Apply'),
onPressed: _onOkay,
),
],
);
}
void _onOkay() {
Navi.popDialog(context);
List<MessageFilterChiplet> chiplets = [];
if (_controller.text.isNotEmpty) {
chiplets.add(MessageFilterChiplet(
label: _controller.text,
value: _controller.text,
type: MessageFilterChipletType.plainSearch,
));
}
AppEvents().notifyFilterListeners([MessageFilterChipletType.plainSearch], chiplets);
}
}

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:simplecloudnotifier/api/api_client.dart'; import 'package:simplecloudnotifier/api/api_client.dart';
import 'package:simplecloudnotifier/components/error_display/error_display.dart';
import 'package:simplecloudnotifier/pages/message_list/message_filter_chiplet.dart'; import 'package:simplecloudnotifier/pages/message_list/message_filter_chiplet.dart';
import 'package:simplecloudnotifier/state/app_auth.dart'; import 'package:simplecloudnotifier/state/app_auth.dart';
import 'package:simplecloudnotifier/state/app_events.dart'; import 'package:simplecloudnotifier/state/app_events.dart';
@ -14,18 +15,17 @@ class FilterModalSendername extends StatefulWidget {
class _FilterModalSendernameState extends State<FilterModalSendername> { class _FilterModalSendernameState extends State<FilterModalSendername> {
Set<String> _selectedEntries = {}; Set<String> _selectedEntries = {};
late ImmediateFuture<List<String>>? _futureSenders; ImmediateFuture<List<String>> _futureSenders = ImmediateFuture.ofPending();
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_futureSenders = null;
_futureSenders = ImmediateFuture.ofFuture(() async { _futureSenders = ImmediateFuture.ofFuture(() async {
final userAcc = Provider.of<AppAuth>(context, listen: false); final userAcc = Provider.of<AppAuth>(context, listen: false);
if (!userAcc.isAuth()) throw new Exception('not logged in'); if (!userAcc.isAuth()) throw new Exception('not logged in');
final senders = await APIClient.getSenderNameList(userAcc); final senders = (await APIClient.getSenderNameList(userAcc)).map((p) => p.name).toList();
return senders; return senders;
}()); }());
@ -48,40 +48,34 @@ class _FilterModalSendernameState extends State<FilterModalSendername> {
content: Container( content: Container(
width: 9000, width: 9000,
height: 9000, height: 9000,
child: () { child: FutureBuilder(
if (_futureSenders == null) { future: _futureSenders.future,
return Center(child: CircularProgressIndicator()); builder: ((context, snapshot) {
} if (_futureSenders.value != null) {
return _buildList(context, _futureSenders.value!);
return FutureBuilder( } else if (snapshot.connectionState == ConnectionState.waiting) {
future: _futureSenders!.future, return Center(child: CircularProgressIndicator());
builder: ((context, snapshot) { } else if (snapshot.connectionState == ConnectionState.done && snapshot.hasError) {
if (_futureSenders?.value != null) { return ErrorDisplay(errorMessage: '${snapshot.error}');
return _buildList(context, _futureSenders!.value!); } else if (snapshot.connectionState == ConnectionState.done && snapshot.hasData) {
} else if (snapshot.connectionState == ConnectionState.done && snapshot.hasError) { return _buildList(context, snapshot.data!);
return Text('Error: ${snapshot.error}'); //TODO better error display } else {
} else if (snapshot.connectionState == ConnectionState.done) { return ErrorDisplay(errorMessage: 'Invalid future state');
return _buildList(context, snapshot.data!); }
} else { }),
return Center(child: CircularProgressIndicator()); ),
}
}),
);
}(),
), ),
actions: <Widget>[ actions: <Widget>[
TextButton( TextButton(
style: TextButton.styleFrom(textStyle: Theme.of(context).textTheme.labelLarge), style: TextButton.styleFrom(textStyle: Theme.of(context).textTheme.labelLarge),
child: const Text('Apply'), child: const Text('Apply'),
onPressed: () { onPressed: _onOkay,
onOkay();
},
), ),
], ],
); );
} }
void onOkay() { void _onOkay() {
Navigator.of(context).pop(); Navigator.of(context).pop();
final chiplets = _selectedEntries final chiplets = _selectedEntries

View File

@ -1,10 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:simplecloudnotifier/utils/navi.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 FilterModalTime extends StatefulWidget { class FilterModalTime extends StatefulWidget {
@override @override
@ -42,7 +37,7 @@ class _FilterModalTimeState extends State<FilterModalTime> {
} }
void onOkay() { void onOkay() {
Navigator.of(context).pop(); Navi.popDialog(context);
//TODO //TODO
} }

View File

@ -9,11 +9,12 @@ import 'package:hive_flutter/hive_flutter.dart';
import 'package:simplecloudnotifier/api/api_client.dart'; import 'package:simplecloudnotifier/api/api_client.dart';
import 'package:simplecloudnotifier/models/channel.dart'; import 'package:simplecloudnotifier/models/channel.dart';
import 'package:simplecloudnotifier/models/client.dart'; import 'package:simplecloudnotifier/models/client.dart';
import 'package:simplecloudnotifier/models/keytoken.dart';
import 'package:simplecloudnotifier/models/scn_message.dart'; import 'package:simplecloudnotifier/models/scn_message.dart';
import 'package:simplecloudnotifier/nav_layout.dart'; import 'package:simplecloudnotifier/nav_layout.dart';
import 'package:simplecloudnotifier/pages/channel_view/channel_view.dart'; import 'package:simplecloudnotifier/pages/channel_view/channel_view.dart';
import 'package:simplecloudnotifier/pages/message_view/message_view.dart'; import 'package:simplecloudnotifier/pages/message_view/message_view.dart';
import 'package:simplecloudnotifier/settings/app_settings.dart'; import 'package:simplecloudnotifier/state/app_settings.dart';
import 'package:simplecloudnotifier/state/app_bar_state.dart'; import 'package:simplecloudnotifier/state/app_bar_state.dart';
import 'package:simplecloudnotifier/state/app_events.dart'; import 'package:simplecloudnotifier/state/app_events.dart';
import 'package:simplecloudnotifier/state/app_theme.dart'; import 'package:simplecloudnotifier/state/app_theme.dart';
@ -50,6 +51,7 @@ void main() async {
Hive.registerAdapter(SCNMessageAdapter()); Hive.registerAdapter(SCNMessageAdapter());
Hive.registerAdapter(ChannelAdapter()); Hive.registerAdapter(ChannelAdapter());
Hive.registerAdapter(FBMessageAdapter()); Hive.registerAdapter(FBMessageAdapter());
Hive.registerAdapter(KeyTokenAdapter());
print('[INIT] Load Hive<scn-logs>...'); print('[INIT] Load Hive<scn-logs>...');
@ -59,6 +61,7 @@ void main() async {
Hive.deleteBoxFromDisk('scn-logs'); Hive.deleteBoxFromDisk('scn-logs');
await Hive.openBox<SCNLog>('scn-logs'); await Hive.openBox<SCNLog>('scn-logs');
ApplicationLog.error('Failed to open Hive-Box: scn-logs: ' + exc.toString(), trace: trace); 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>...'); print('[INIT] Load Hive<scn-requests>...');
@ -69,6 +72,7 @@ void main() async {
Hive.deleteBoxFromDisk('scn-requests'); Hive.deleteBoxFromDisk('scn-requests');
await Hive.openBox<SCNRequest>('scn-requests'); await Hive.openBox<SCNRequest>('scn-requests');
ApplicationLog.error('Failed to open Hive-Box: scn-requests: ' + exc.toString(), trace: trace); 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>...'); print('[INIT] Load Hive<scn-message-cache>...');
@ -79,6 +83,7 @@ void main() async {
Hive.deleteBoxFromDisk('scn-message-cache'); Hive.deleteBoxFromDisk('scn-message-cache');
await Hive.openBox<SCNMessage>('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.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>...'); print('[INIT] Load Hive<scn-channel-cache>...');
@ -89,6 +94,7 @@ void main() async {
Hive.deleteBoxFromDisk('scn-channel-cache'); Hive.deleteBoxFromDisk('scn-channel-cache');
await Hive.openBox<Channel>('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.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>...'); print('[INIT] Load Hive<scn-fb-messages>...');
@ -99,6 +105,18 @@ void main() async {
Hive.deleteBoxFromDisk('scn-fb-messages'); Hive.deleteBoxFromDisk('scn-fb-messages');
await Hive.openBox<FBMessage>('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.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 Hive<scn-keytoken-value-cache>...');
try {
await Hive.openBox<KeyToken>('scn-keytoken-value-cache');
} catch (exc, trace) {
Hive.deleteBoxFromDisk('scn-keytoken-value-cache');
await Hive.openBox<KeyToken>('scn-keytoken-value-cache');
ApplicationLog.error('Failed to open Hive-Box: scn-keytoken-value-cache' + exc.toString(), trace: trace);
ApplicationLog.writeRawFailure('Failed to open Hive-Box: scn-keytoken-value-cache', {'error': exc.toString(), 'trace': trace});
} }
print('[INIT] Load AppAuth...'); print('[INIT] Load AppAuth...');
@ -112,11 +130,13 @@ void main() async {
await appAuth.loadUser(); await appAuth.loadUser();
} catch (exc, trace) { } catch (exc, trace) {
ApplicationLog.error('Failed to load user (background load 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 { try {
await appAuth.loadClient(); await appAuth.loadClient();
} catch (exc, trace) { } catch (exc, trace) {
ApplicationLog.error('Failed to load user (background load 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});
} }
}(); }();
} }
@ -219,7 +239,7 @@ class SCNApp extends StatelessWidget {
return ToastificationWrapper( return ToastificationWrapper(
config: ToastificationConfig( config: ToastificationConfig(
itemWidth: 440, itemWidth: 440,
marginBuilder: (alignment) => EdgeInsets.symmetric(vertical: 64), marginBuilder: (context, alignment) => EdgeInsets.symmetric(vertical: 64),
animationDuration: Duration(milliseconds: 200), animationDuration: Duration(milliseconds: 200),
), ),
child: Consumer<AppTheme>( child: Consumer<AppTheme>(
@ -228,8 +248,10 @@ class SCNApp extends StatelessWidget {
title: 'SimpleCloudNotifier', title: 'SimpleCloudNotifier',
navigatorObservers: [Navi.routeObserver, Navi.modalRouteObserver], navigatorObservers: [Navi.routeObserver, Navi.modalRouteObserver],
theme: ThemeData( theme: ThemeData(
//TODO color settings colorScheme: ColorScheme.fromSeed(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue, brightness: appTheme.darkMode ? Brightness.dark : Brightness.light), seedColor: appTheme.color.value,
brightness: appTheme.darkMode ? Brightness.dark : Brightness.light,
),
useMaterial3: true, useMaterial3: true,
), ),
home: SCNNavLayout(), home: SCNNavLayout(),
@ -275,7 +297,7 @@ void setFirebaseToken(String fcmToken) async {
acc.setClientAndClientID(newClient); acc.setClientAndClientID(newClient);
await acc.save(); await acc.save();
} else { } else {
final newClient = await APIClient.updateClient(acc, client.clientID, fcmToken, Globals().deviceModel, Globals().hostname, Globals().version); final newClient = await APIClient.updateClient(acc, client.clientID, fcmToken: fcmToken, agentModel: Globals().deviceModel, name: Globals().hostname, agentVersion: Globals().version);
acc.setClientAndClientID(newClient); acc.setClientAndClientID(newClient);
await acc.save(); await acc.save();
} }
@ -321,8 +343,9 @@ Future<void> _receiveMessage(RemoteMessage message, bool foreground) async {
await Hive.openBox<SCNMessage>('scn-message-cache'); await Hive.openBox<SCNMessage>('scn-message-cache');
await Hive.openBox<SCNRequest>('scn-requests'); await Hive.openBox<SCNRequest>('scn-requests');
} catch (exc, trace) { } 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); 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); Notifier.showLocalNotification("", "@ERROR", "@ERROR", 'Error Channel', "Error", "Failed to receive SCN message (init failed)", null, null);
return; return;
} }
@ -338,19 +361,22 @@ Future<void> _receiveMessage(RemoteMessage message, bool foreground) async {
final channel = message.data['channel'] as String; final channel = message.data['channel'] as String;
final channel_id = message.data['channel_id'] as String; final channel_id = message.data['channel_id'] as String;
final body = message.data['body'] as String; final body = message.data['body'] as String;
final prio = int.parse(message.data['priority'] as String);
Notifier.showLocalNotification(scn_msg_id, channel_id, channel, 'Channel: ${channel}', title, body, timestamp); Notifier.showLocalNotification(scn_msg_id, channel_id, channel, 'Channel: ${channel}', title, body, timestamp, prio);
} catch (exc, trace) { } 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); 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); Notifier.showLocalNotification("", "@ERROR", "@ERROR", 'Error Channel', "Error", "Failed to receive SCN message (decode failed)", null, null);
return; return;
} }
try { try {
FBMessageLog.insert(message); FBMessageLog.insert(message);
} catch (exc, trace) { } 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); 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); Notifier.showLocalNotification("", "@ERROR", "@ERROR", 'Error Channel', "Error", "Failed to receive SCN message (persist failed)", null, null);
return; return;
} }
@ -359,6 +385,7 @@ Future<void> _receiveMessage(RemoteMessage message, bool foreground) async {
SCNDataCache().addToMessageCache([msg]); SCNDataCache().addToMessageCache([msg]);
if (foreground) AppEvents().notifyMessageReceivedListeners(msg); if (foreground) AppEvents().notifyMessageReceivedListeners(msg);
} catch (exc, trace) { } 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); ApplicationLog.error('Failed to query+persist message: ' + exc.toString(), trace: trace);
return; return;
} }
@ -382,7 +409,7 @@ void _handleNotificationClickAction(String? payload, Duration delay) {
if (parts.length == 4 && parts[0] == '@SCN_MESSAGE') { if (parts.length == 4 && parts[0] == '@SCN_MESSAGE') {
final messageID = parts[1]; final messageID = parts[1];
() async { () async {
await Future.delayed(delay); await Future.delayed(delay, () {});
SchedulerBinding.instance.addPostFrameCallback((_) { SchedulerBinding.instance.addPostFrameCallback((_) {
ApplicationLog.info('Handle notification action @SCN_MESSAGE --> ${messageID}'); ApplicationLog.info('Handle notification action @SCN_MESSAGE --> ${messageID}');
@ -392,7 +419,7 @@ void _handleNotificationClickAction(String? payload, Duration delay) {
} else if (parts.length == 3 && parts[0] == '@SCN_MESSAGE_SUMMARY') { } else if (parts.length == 3 && parts[0] == '@SCN_MESSAGE_SUMMARY') {
final channelID = parts[1]; final channelID = parts[1];
() async { () async {
await Future.delayed(delay); await Future.delayed(delay, () {});
SchedulerBinding.instance.addPostFrameCallback((_) { SchedulerBinding.instance.addPostFrameCallback((_) {
ApplicationLog.info('Handle notification action @SCN_MESSAGE_SUMMARY --> ${channelID}'); ApplicationLog.info('Handle notification action @SCN_MESSAGE_SUMMARY --> ${channelID}');

View File

@ -1,7 +1,7 @@
class APIError { class APIError {
final bool success; final bool success;
final int error; final int error;
final String errhighlight; final int errhighlight;
final String message; final String message;
static final MISSING_UID = 1101; static final MISSING_UID = 1101;
@ -67,7 +67,7 @@ class APIError {
return APIError( return APIError(
success: json['success'] as bool, success: json['success'] as bool,
error: (json['error'] as num).toInt(), error: (json['error'] as num).toInt(),
errhighlight: json['errhighlight'] as String, errhighlight: (json['errhighlight'] as num).toInt(),
message: json['message'] as String, message: json['message'] as String,
); );
} }

View File

@ -71,13 +71,15 @@ class Channel extends HiveObject implements FieldDebuggable {
]; ];
} }
ChannelPreview toPreview() { ChannelPreview toPreview(Subscription? sub) {
return ChannelPreview( return ChannelPreview(
channelID: this.channelID, channelID: this.channelID,
ownerUserID: this.ownerUserID, ownerUserID: this.ownerUserID,
internalName: this.internalName, internalName: this.internalName,
displayName: this.displayName, displayName: this.displayName,
descriptionName: this.descriptionName, descriptionName: this.descriptionName,
messagesSent: this.messagesSent,
subscription: sub,
); );
} }
} }
@ -109,6 +111,8 @@ class ChannelPreview {
final String internalName; final String internalName;
final String displayName; final String displayName;
final String? descriptionName; final String? descriptionName;
final int messagesSent;
final Subscription? subscription;
const ChannelPreview({ const ChannelPreview({
required this.channelID, required this.channelID,
@ -116,6 +120,8 @@ class ChannelPreview {
required this.internalName, required this.internalName,
required this.displayName, required this.displayName,
required this.descriptionName, required this.descriptionName,
required this.messagesSent,
required this.subscription,
}); });
factory ChannelPreview.fromJson(Map<String, dynamic> json) { factory ChannelPreview.fromJson(Map<String, dynamic> json) {
@ -125,6 +131,8 @@ class ChannelPreview {
internalName: json['internal_name'] as String, internalName: json['internal_name'] as String,
displayName: json['display_name'] as String, displayName: json['display_name'] as String,
descriptionName: json['description_name'] as String?, descriptionName: json['description_name'] as String?,
messagesSent: json['messages_sent'] as int,
subscription: json['subscription'] == null ? null : Subscription.fromJson(json['subscription'] as Map<String, dynamic>),
); );
} }
} }

View File

@ -1,19 +1,34 @@
import 'package:hive_flutter/hive_flutter.dart';
part 'keytoken.g.dart';
@HiveType(typeId: 107)
class KeyToken { class KeyToken {
@HiveField(0)
final String keytokenID; final String keytokenID;
@HiveField(10)
final String name; final String name;
@HiveField(11)
final String timestampCreated; final String timestampCreated;
final String? timestampLastused; @HiveField(13)
final String? timestampLastUsed;
@HiveField(14)
final String ownerUserID; final String ownerUserID;
@HiveField(15)
final bool allChannels; final bool allChannels;
@HiveField(16)
final List<String> channels; final List<String> channels;
@HiveField(17)
final String permissions; final String permissions;
@HiveField(18)
final int messagesSent; final int messagesSent;
const KeyToken({ const KeyToken({
required this.keytokenID, required this.keytokenID,
required this.name, required this.name,
required this.timestampCreated, required this.timestampCreated,
required this.timestampLastused, required this.timestampLastUsed,
required this.ownerUserID, required this.ownerUserID,
required this.allChannels, required this.allChannels,
required this.channels, required this.channels,
@ -26,7 +41,7 @@ class KeyToken {
keytokenID: json['keytoken_id'] as String, keytokenID: json['keytoken_id'] as String,
name: json['name'] as String, name: json['name'] as String,
timestampCreated: json['timestamp_created'] as String, timestampCreated: json['timestamp_created'] as String,
timestampLastused: json['timestamp_lastused'] as String?, timestampLastUsed: json['timestamp_lastused'] as String?,
ownerUserID: json['owner_user_id'] as String, ownerUserID: json['owner_user_id'] as String,
allChannels: json['all_channels'] as bool, allChannels: json['all_channels'] as bool,
channels: (json['channels'] as List<dynamic>).map((e) => e as String).toList(), channels: (json['channels'] as List<dynamic>).map((e) => e as String).toList(),
@ -38,6 +53,34 @@ class KeyToken {
static List<KeyToken> fromJsonArray(List<dynamic> jsonArr) { static List<KeyToken> fromJsonArray(List<dynamic> jsonArr) {
return jsonArr.map<KeyToken>((e) => KeyToken.fromJson(e as Map<String, dynamic>)).toList(); return jsonArr.map<KeyToken>((e) => KeyToken.fromJson(e as Map<String, dynamic>)).toList();
} }
KeyTokenPreview toPreview() {
return KeyTokenPreview(
keytokenID: keytokenID,
name: name,
ownerUserID: ownerUserID,
allChannels: allChannels,
channels: channels,
permissions: permissions,
);
}
}
class KeyTokenWithToken {
final KeyToken keyToken;
final String token;
KeyTokenWithToken({
required this.keyToken,
required this.token,
});
factory KeyTokenWithToken.fromJson(Map<String, dynamic> json) {
return KeyTokenWithToken(
keyToken: KeyToken.fromJson(json),
token: json['token'] as String,
);
}
} }
class KeyTokenPreview { class KeyTokenPreview {

View File

@ -0,0 +1,65 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'keytoken.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class KeyTokenAdapter extends TypeAdapter<KeyToken> {
@override
final int typeId = 107;
@override
KeyToken read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return KeyToken(
keytokenID: fields[0] as String,
name: fields[10] as String,
timestampCreated: fields[11] as String,
timestampLastUsed: fields[13] as String?,
ownerUserID: fields[14] as String,
allChannels: fields[15] as bool,
channels: (fields[16] as List).cast<String>(),
permissions: fields[17] as String,
messagesSent: fields[18] as int,
);
}
@override
void write(BinaryWriter writer, KeyToken obj) {
writer
..writeByte(9)
..writeByte(0)
..write(obj.keytokenID)
..writeByte(10)
..write(obj.name)
..writeByte(11)
..write(obj.timestampCreated)
..writeByte(13)
..write(obj.timestampLastUsed)
..writeByte(14)
..write(obj.ownerUserID)
..writeByte(15)
..write(obj.allChannels)
..writeByte(16)
..write(obj.channels)
..writeByte(17)
..write(obj.permissions)
..writeByte(18)
..write(obj.messagesSent);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is KeyTokenAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@ -0,0 +1,85 @@
import 'package:simplecloudnotifier/models/channel.dart';
enum ScanResultMode { ChannelSubscribe, MessageSend, Channel, Error }
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'], url: 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: null, url: lines[0]);
}
}
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 != 5) return null;
return ScanResultChannel(channelDisplayName: lines[2], ownerUserID: lines[3], channelID: lines[4]);
}
return ScanResultError(message: 'Invalid QR code');
}
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;
final String url;
ScanResultMessageSend({required this.userID, required this.userKey, required this.url});
@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;
}
class ScanResultError extends ScanResult {
final String message;
ScanResultError({required this.message});
@override
ScanResultMode get mode => ScanResultMode.Error;
}

View File

@ -0,0 +1,55 @@
class SendMessageResponse {
final bool success;
final int errorID;
final int errorHighlight;
final String message;
final bool suppressSend;
final int messageCount;
final int quota;
final bool isPro;
final int quotaMax;
final String scnMessageID;
SendMessageResponse({
required this.success,
required this.errorID,
required this.errorHighlight,
required this.message,
required this.suppressSend,
required this.messageCount,
required this.quota,
required this.isPro,
required this.quotaMax,
required this.scnMessageID,
});
factory SendMessageResponse.fromJson(Map<String, dynamic> json) {
return SendMessageResponse(
success: json['success'] as bool,
errorID: json['error'] as int,
errorHighlight: json['errhighlight'] as int,
message: json['message'] as String,
suppressSend: json['suppress_send'] as bool,
messageCount: json['messagecount'] as int,
quota: json['quota'] as int,
isPro: json['is_pro'] as bool,
quotaMax: json['quota_max'] as int,
scnMessageID: json['scn_msg_id'] as String,
);
}
Map<String, dynamic> toJson() {
return {
'success': success,
'error': errorID,
'errhighlight': errorHighlight,
'message': message,
'suppress_send': suppressSend,
'messagecount': messageCount,
'quota': quota,
'is_pro': isPro,
'quota_max': quotaMax,
'scn_msg_id': scnMessageID,
};
}
}

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

@ -6,6 +6,7 @@ class Subscription {
final String channelInternalName; final String channelInternalName;
final String timestampCreated; final String timestampCreated;
final bool confirmed; final bool confirmed;
final bool active;
const Subscription({ const Subscription({
required this.subscriptionID, required this.subscriptionID,
@ -15,6 +16,7 @@ class Subscription {
required this.channelInternalName, required this.channelInternalName,
required this.timestampCreated, required this.timestampCreated,
required this.confirmed, required this.confirmed,
required this.active,
}); });
factory Subscription.fromJson(Map<String, dynamic> json) { factory Subscription.fromJson(Map<String, dynamic> json) {
@ -26,6 +28,7 @@ class Subscription {
channelInternalName: json['channel_internal_name'] as String, channelInternalName: json['channel_internal_name'] as String,
timestampCreated: json['timestamp_created'] as String, timestampCreated: json['timestamp_created'] as String,
confirmed: json['confirmed'] as bool, confirmed: json['confirmed'] as bool,
active: json['active'] as bool,
); );
} }

View File

@ -8,7 +8,7 @@ import 'package:simplecloudnotifier/pages/send/send.dart';
import 'package:simplecloudnotifier/components/bottom_fab/fab_bottom_app_bar.dart'; import 'package:simplecloudnotifier/components/bottom_fab/fab_bottom_app_bar.dart';
import 'package:simplecloudnotifier/pages/account/account.dart'; import 'package:simplecloudnotifier/pages/account/account.dart';
import 'package:simplecloudnotifier/pages/message_list/message_list.dart'; import 'package:simplecloudnotifier/pages/message_list/message_list.dart';
import 'package:simplecloudnotifier/pages/settings/root.dart'; import 'package:simplecloudnotifier/pages/settings/settings_view.dart';
import 'package:simplecloudnotifier/state/app_auth.dart'; import 'package:simplecloudnotifier/state/app_auth.dart';
import 'package:simplecloudnotifier/utils/toaster.dart'; import 'package:simplecloudnotifier/utils/toaster.dart';

View File

@ -5,13 +5,21 @@ import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:simplecloudnotifier/api/api_client.dart'; import 'package:simplecloudnotifier/api/api_client.dart';
import 'package:simplecloudnotifier/components/error_display/error_display.dart';
import 'package:simplecloudnotifier/models/user.dart'; import 'package:simplecloudnotifier/models/user.dart';
import 'package:simplecloudnotifier/pages/account/login.dart'; import 'package:simplecloudnotifier/pages/account/login.dart';
import 'package:simplecloudnotifier/pages/channel_list/channel_list_extended.dart';
import 'package:simplecloudnotifier/pages/client_list/client_list.dart';
import 'package:simplecloudnotifier/pages/filtered_message_view/filtered_message_view.dart';
import 'package:simplecloudnotifier/pages/keytoken_list/keytoken_list.dart';
import 'package:simplecloudnotifier/pages/sender_list/sender_list.dart';
import 'package:simplecloudnotifier/pages/subscription_list/subscription_list.dart';
import 'package:simplecloudnotifier/state/app_bar_state.dart'; import 'package:simplecloudnotifier/state/app_bar_state.dart';
import 'package:simplecloudnotifier/state/application_log.dart'; import 'package:simplecloudnotifier/state/application_log.dart';
import 'package:simplecloudnotifier/state/globals.dart'; import 'package:simplecloudnotifier/state/globals.dart';
import 'package:simplecloudnotifier/state/app_auth.dart'; import 'package:simplecloudnotifier/state/app_auth.dart';
import 'package:simplecloudnotifier/types/immediate_future.dart'; import 'package:simplecloudnotifier/types/immediate_future.dart';
import 'package:simplecloudnotifier/utils/dialogs.dart';
import 'package:simplecloudnotifier/utils/navi.dart'; import 'package:simplecloudnotifier/utils/navi.dart';
import 'package:simplecloudnotifier/utils/toaster.dart'; import 'package:simplecloudnotifier/utils/toaster.dart';
import 'package:simplecloudnotifier/utils/ui.dart'; import 'package:simplecloudnotifier/utils/ui.dart';
@ -27,12 +35,13 @@ class AccountRootPage extends StatefulWidget {
} }
class _AccountRootPageState extends State<AccountRootPage> { class _AccountRootPageState extends State<AccountRootPage> {
late ImmediateFuture<int>? futureSubscriptionCount; ImmediateFuture<int> _futureSubscriptionCount = ImmediateFuture.ofPending();
late ImmediateFuture<int>? futureClientCount; ImmediateFuture<int> _futureClientCount = ImmediateFuture.ofPending();
late ImmediateFuture<int>? futureKeyCount; ImmediateFuture<int> _futureKeyCount = ImmediateFuture.ofPending();
late ImmediateFuture<int>? futureChannelAllCount; ImmediateFuture<int> _futureChannelAllCount = ImmediateFuture.ofPending();
late ImmediateFuture<int>? futureChannelSubscribedCount; ImmediateFuture<int> _futureChannelOwnedCount = ImmediateFuture.ofPending();
late ImmediateFuture<User>? futureUser; ImmediateFuture<int> _futureSenderNamesCount = ImmediateFuture.ofPending();
ImmediateFuture<User> _futureUser = ImmediateFuture.ofPending();
late AppAuth userAcc; late AppAuth userAcc;
@ -82,44 +91,51 @@ class _AccountRootPageState extends State<AccountRootPage> {
} }
void _createFutures() { void _createFutures() {
futureSubscriptionCount = null; _futureSubscriptionCount = ImmediateFuture.ofPending();
futureClientCount = null; _futureClientCount = ImmediateFuture.ofPending();
futureKeyCount = null; _futureKeyCount = ImmediateFuture.ofPending();
futureChannelAllCount = null; _futureChannelAllCount = ImmediateFuture.ofPending();
futureChannelSubscribedCount = null; _futureChannelOwnedCount = ImmediateFuture.ofPending();
_futureSenderNamesCount = ImmediateFuture.ofPending();
if (userAcc.isAuth()) { if (userAcc.isAuth()) {
futureChannelAllCount = ImmediateFuture.ofFuture(() async { _futureChannelAllCount = ImmediateFuture.ofFuture(() async {
if (!userAcc.isAuth()) throw new Exception('not logged in'); if (!userAcc.isAuth()) throw new Exception('not logged in');
final channels = await APIClient.getChannelList(userAcc, ChannelSelector.all); final channels = await APIClient.getChannelList(userAcc, ChannelSelector.all);
return channels.length; return channels.length;
}()); }());
futureChannelSubscribedCount = ImmediateFuture.ofFuture(() async { _futureChannelOwnedCount = ImmediateFuture.ofFuture(() async {
if (!userAcc.isAuth()) throw new Exception('not logged in'); if (!userAcc.isAuth()) throw new Exception('not logged in');
final channels = await APIClient.getChannelList(userAcc, ChannelSelector.subscribed); final channels = await APIClient.getChannelList(userAcc, ChannelSelector.owned);
return channels.length; return channels.length;
}()); }());
futureSubscriptionCount = ImmediateFuture.ofFuture(() async { _futureSubscriptionCount = ImmediateFuture.ofFuture(() async {
if (!userAcc.isAuth()) throw new Exception('not logged in'); if (!userAcc.isAuth()) throw new Exception('not logged in');
final subs = await APIClient.getSubscriptionList(userAcc); final subs = await APIClient.getSubscriptionList(userAcc);
return subs.length; return subs.length;
}()); }());
futureClientCount = ImmediateFuture.ofFuture(() async { _futureClientCount = ImmediateFuture.ofFuture(() async {
if (!userAcc.isAuth()) throw new Exception('not logged in'); if (!userAcc.isAuth()) throw new Exception('not logged in');
final clients = await APIClient.getClientList(userAcc); final clients = await APIClient.getClientList(userAcc);
return clients.length; return clients.length;
}()); }());
futureKeyCount = ImmediateFuture.ofFuture(() async { _futureKeyCount = ImmediateFuture.ofFuture(() async {
if (!userAcc.isAuth()) throw new Exception('not logged in'); if (!userAcc.isAuth()) throw new Exception('not logged in');
final keys = await APIClient.getKeyTokenList(userAcc); final keys = await APIClient.getKeyTokenList(userAcc);
return keys.length; return keys.length;
}()); }());
futureUser = ImmediateFuture.ofFuture(userAcc.loadUser(force: false)); _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));
} }
} }
@ -133,19 +149,19 @@ class _AccountRootPageState extends State<AccountRootPage> {
// refresh all data and then replace teh futures used in build() // refresh all data and then replace teh futures used in build()
final channelsAll = await APIClient.getChannelList(userAcc, ChannelSelector.all); final channelsAll = await APIClient.getChannelList(userAcc, ChannelSelector.all);
final channelsSubscribed = await APIClient.getChannelList(userAcc, ChannelSelector.subscribed);
final subs = await APIClient.getSubscriptionList(userAcc); final subs = await APIClient.getSubscriptionList(userAcc);
final clients = await APIClient.getClientList(userAcc); final clients = await APIClient.getClientList(userAcc);
final keys = await APIClient.getKeyTokenList(userAcc); final keys = await APIClient.getKeyTokenList(userAcc);
final senderNames = await APIClient.getSenderNameList(userAcc);
final user = await userAcc.loadUser(force: true); final user = await userAcc.loadUser(force: true);
setState(() { setState(() {
futureChannelAllCount = ImmediateFuture.ofValue(channelsAll.length); _futureChannelAllCount = ImmediateFuture.ofValue(channelsAll.length);
futureChannelSubscribedCount = ImmediateFuture.ofValue(channelsSubscribed.length); _futureSubscriptionCount = ImmediateFuture.ofValue(subs.length);
futureSubscriptionCount = ImmediateFuture.ofValue(subs.length); _futureClientCount = ImmediateFuture.ofValue(clients.length);
futureClientCount = ImmediateFuture.ofValue(clients.length); _futureKeyCount = ImmediateFuture.ofValue(keys.length);
futureKeyCount = ImmediateFuture.ofValue(keys.length); _futureSenderNamesCount = ImmediateFuture.ofValue(senderNames.length);
futureUser = ImmediateFuture.ofValue(user); _futureUser = ImmediateFuture.ofValue(user);
}); });
} catch (exc, trace) { } catch (exc, trace) {
ApplicationLog.error('Failed to refresh account data: ' + exc.toString(), trace: trace); ApplicationLog.error('Failed to refresh account data: ' + exc.toString(), trace: trace);
@ -166,12 +182,12 @@ class _AccountRootPageState extends State<AccountRootPage> {
return _buildNoAuth(context); return _buildNoAuth(context);
} else { } else {
return FutureBuilder( return FutureBuilder(
future: futureUser!.future, future: _futureUser.future,
builder: ((context, snapshot) { builder: ((context, snapshot) {
if (futureUser?.value != null) { if (_futureUser.value != null) {
return _buildShowAccount(context, acc, futureUser!.value!); return _buildShowAccount(context, acc, _futureUser.value!);
} else if (snapshot.connectionState == ConnectionState.done && snapshot.hasError) { } else if (snapshot.connectionState == ConnectionState.done && snapshot.hasError) {
return Text('Error: ${snapshot.error}'); //TODO better error display return ErrorDisplay(errorMessage: '${snapshot.error}');
} else if (snapshot.connectionState == ConnectionState.done) { } else if (snapshot.connectionState == ConnectionState.done) {
return _buildShowAccount(context, acc, snapshot.data!); return _buildShowAccount(context, acc, snapshot.data!);
} else { } else {
@ -328,10 +344,12 @@ class _AccountRootPageState extends State<AccountRootPage> {
children: [ children: [
SizedBox(width: 80, child: Text("Channels", style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(160)))), SizedBox(width: 80, child: Text("Channels", style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(160)))),
FutureBuilder( FutureBuilder(
future: futureChannelAllCount!.future, future: _futureChannelOwnedCount.future,
builder: (context, snapshot) { builder: (context, snapshot) {
if (futureChannelAllCount?.value != null) { if (_futureChannelOwnedCount.value != null) {
return Text('${futureChannelAllCount!.value}'); return Text('${_futureChannelOwnedCount.value}');
} else if (snapshot.connectionState == ConnectionState.done && snapshot.hasError) {
return Text('ERROR: ${snapshot.error}', style: TextStyle(color: Colors.red));
} else if (snapshot.connectionState == ConnectionState.done) { } else if (snapshot.connectionState == ConnectionState.done) {
return Text('${snapshot.data}'); return Text('${snapshot.data}');
} else { } else {
@ -348,13 +366,15 @@ class _AccountRootPageState extends State<AccountRootPage> {
mainAxisAlignment: MainAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start,
children: [ children: [
UI.buttonIconOnly( UI.buttonIconOnly(
onPressed: () {/*TODO*/}, onPressed: _changeUsername,
icon: FontAwesomeIcons.pen, icon: FontAwesomeIcons.pen,
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
if (!user.isPro) if (!user.isPro)
UI.buttonIconOnly( UI.buttonIconOnly(
onPressed: () {/*TODO*/}, onPressed: () {
Toaster.info("Not Implemented", "Account Upgrading will be implemented in a later version"); // TODO
},
icon: FontAwesomeIcons.cartCircleArrowUp, icon: FontAwesomeIcons.cartCircleArrowUp,
), ),
], ],
@ -365,10 +385,11 @@ class _AccountRootPageState extends State<AccountRootPage> {
List<Widget> _buildCards(BuildContext context, User user) { List<Widget> _buildCards(BuildContext context, User user) {
return [ return [
_buildNumberCard(context, 'Subscriptions', futureSubscriptionCount, () {/*TODO*/}), _buildNumberCard(context, 'Subscription', 's', _futureSubscriptionCount, () => Navi.push(context, () => SubscriptionListPage())),
_buildNumberCard(context, 'Clients', futureClientCount, () {/*TODO*/}), _buildNumberCard(context, 'Client', 's', _futureClientCount, () => Navi.push(context, () => ClientListPage())),
_buildNumberCard(context, 'Keys', futureKeyCount, () {/*TODO*/}), _buildNumberCard(context, 'Key', 's', _futureKeyCount, () => Navi.push(context, () => KeyTokenListPage())),
_buildNumberCard(context, 'Channels', futureChannelSubscribedCount, () {/*TODO*/}), _buildNumberCard(context, 'Channel', 's', _futureChannelAllCount, () => Navi.push(context, () => ChannelListExtendedPage())),
_buildNumberCard(context, 'Sender', '', _futureSenderNamesCount, () => Navi.push(context, () => SenderListPage())),
UI.buttonCard( UI.buttonCard(
context: context, context: context,
margin: EdgeInsets.fromLTRB(0, 4, 0, 4), margin: EdgeInsets.fromLTRB(0, 4, 0, 4),
@ -379,22 +400,26 @@ class _AccountRootPageState extends State<AccountRootPage> {
Text('Messages', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20)), Text('Messages', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20)),
], ],
), ),
onTap: () {/*TODO*/}, onTap: () {
Navi.push(context, () => FilteredMessageViewPage(title: "All Messages", filter: MessageFilter(senderUserID: [user.userID])));
},
), ),
]; ];
} }
Widget _buildNumberCard(BuildContext context, String txt, ImmediateFuture<int>? future, void Function() action) { Widget _buildNumberCard(BuildContext context, String txt, String pluralSuffix, ImmediateFuture<int> future, void Function() action) {
return UI.buttonCard( return UI.buttonCard(
context: context, context: context,
margin: EdgeInsets.fromLTRB(0, 4, 0, 4), margin: EdgeInsets.fromLTRB(0, 4, 0, 4),
child: Row( child: Row(
children: [ children: [
FutureBuilder( FutureBuilder(
future: future?.future, future: future.future,
builder: (context, snapshot) { builder: (context, snapshot) {
if (future?.value != null) { if (future.value != null) {
return Text('${future?.value}', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20)); return Text('${future.value}', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20));
} else if (snapshot.connectionState == ConnectionState.done && snapshot.hasError) {
return Text('ERROR: ${snapshot.error}', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20, color: Colors.red));
} else if (snapshot.connectionState == ConnectionState.done) { } else if (snapshot.connectionState == ConnectionState.done) {
return Text('${snapshot.data}', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20)); return Text('${snapshot.data}', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20));
} else { } else {
@ -403,7 +428,20 @@ class _AccountRootPageState extends State<AccountRootPage> {
}, },
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Text(txt, style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20)), FutureBuilder(
future: future.future,
builder: (context, snapshot) {
if (future.value != null) {
return Text('${txt}${((future.value != 1) ? pluralSuffix : '')}', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20));
} else if (snapshot.connectionState == ConnectionState.done && snapshot.hasError) {
return Text('ERROR: ${snapshot.error}', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20, color: Colors.red));
} else if (snapshot.connectionState == ConnectionState.done) {
return Text('${txt}${((snapshot.data != 1) ? pluralSuffix : '')}', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20));
} else {
return Text(txt, style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20));
}
},
),
], ],
), ),
onTap: action, onTap: action,
@ -486,6 +524,50 @@ class _AccountRootPageState extends State<AccountRootPage> {
} }
void _deleteAccount() async { void _deleteAccount() async {
//TODO final acc = AppAuth();
if (!acc.isAuth()) return;
try {
TODO ASK BEFORE DELETING TEH FUCKING USER !!!!!!!
await APIClient.deleteUser(acc, acc.userID!);
Toaster.info('Logout', 'Successfully logged out');
acc.clear();
await acc.save();
//TODO clear messages/channels/etc in open views
} catch (exc, trace) {
Toaster.error("Error", 'Failed to delete user');
ApplicationLog.error('Failed to delete user: ' + exc.toString(), trace: trace);
}
}
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

@ -122,9 +122,9 @@ class _AccountLoginPageState extends State<AccountLoginPage> {
try { try {
setState(() => loading = true); setState(() => loading = true);
final uid = _ctrlUserID.text; var uid = _ctrlUserID.text;
final atokv = _ctrlTokenAdmin.text; var atokv = _ctrlTokenAdmin.text;
final stokv = _ctrlTokenSend.text; var stokv = _ctrlTokenSend.text;
final fcmToken = await FirebaseMessaging.instance.getToken(); final fcmToken = await FirebaseMessaging.instance.getToken();
@ -140,11 +140,17 @@ class _AccountLoginPageState extends State<AccountLoginPage> {
return; return;
} }
final toks = await APIClient.getKeyTokenByToken(uid, stokv); if (stokv != "") {
final toks = await APIClient.getKeyTokenByToken(uid, stokv);
if (!toks.allChannels || toks.permissions != 'CS') { if (!toks.allChannels || toks.permissions != 'CS') {
Toaster.error("Error", 'Send token does not have required permissions'); Toaster.error("Error", 'Send token does not have required permissions');
return; return;
}
} else {
final toks = await APIClient.createKeyToken(DirectTokenSource(uid, atokv), "SendKey (auto generated by SCN)", "CS", true);
stokv = toks.token;
} }
final user = await APIClient.getUser(DirectTokenSource(uid, atokv), uid); final user = await APIClient.getUser(DirectTokenSource(uid, atokv), uid);

View File

@ -4,7 +4,7 @@ import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:simplecloudnotifier/api/api_client.dart'; import 'package:simplecloudnotifier/api/api_client.dart';
import 'package:simplecloudnotifier/models/channel.dart'; import 'package:simplecloudnotifier/models/channel.dart';
import 'package:simplecloudnotifier/pages/channel_view/channel_view.dart'; import 'package:simplecloudnotifier/pages/channel_scanner/channel_scanner.dart';
import 'package:simplecloudnotifier/state/app_bar_state.dart'; import 'package:simplecloudnotifier/state/app_bar_state.dart';
import 'package:simplecloudnotifier/state/application_log.dart'; import 'package:simplecloudnotifier/state/application_log.dart';
import 'package:simplecloudnotifier/state/app_auth.dart'; import 'package:simplecloudnotifier/state/app_auth.dart';
@ -154,8 +154,13 @@ class _ChannelRootPageState extends State<ChannelRootPage> with RouteAware {
itemBuilder: (context, item, index) => ChannelListItem( itemBuilder: (context, item, index) => ChannelListItem(
channel: item.channel, channel: item.channel,
subscription: item.subscription, subscription: item.subscription,
onPressed: () { mode: ChannelListItemMode.Messages,
Navi.push(context, () => ChannelViewPage(channelID: item.channel.channelID, preloadedData: (item.channel, item.subscription), needsReload: _enqueueReload)); 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);
});
}, },
), ),
), ),
@ -164,7 +169,7 @@ class _ChannelRootPageState extends State<ChannelRootPage> with RouteAware {
floatingActionButton: FloatingActionButton( floatingActionButton: FloatingActionButton(
heroTag: 'fab_channel_list_qr', heroTag: 'fab_channel_list_qr',
onPressed: () { onPressed: () {
//TODO scan qr code to subscribe channel Navi.push(context, () => ChannelScannerPage());
}, },
child: const Icon(FontAwesomeIcons.qrcode), child: const Icon(FontAwesomeIcons.qrcode),
), ),

View File

@ -0,0 +1,160 @@
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/components/layout/scaffold.dart';
import 'package:simplecloudnotifier/models/channel.dart';
import 'package:simplecloudnotifier/pages/channel_scanner/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 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);
});
},
),
),
),
),
),
floatingActionButton: FloatingActionButton(
heroTag: 'fab_channel_list_extended-plus',
onPressed: () {
Navi.push(context, () => ChannelScannerPage());
},
child: const Icon(FontAwesomeIcons.plus),
),
);
}
void _enqueueReload() {
_reloadEnqueued = true;
}
}

View File

@ -1,32 +1,39 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:simplecloudnotifier/api/api_client.dart'; import 'package:simplecloudnotifier/api/api_client.dart';
import 'package:simplecloudnotifier/models/channel.dart'; import 'package:simplecloudnotifier/models/channel.dart';
import 'package:simplecloudnotifier/models/scn_message.dart'; import 'package:simplecloudnotifier/models/scn_message.dart';
import 'package:simplecloudnotifier/models/subscription.dart'; import 'package:simplecloudnotifier/models/subscription.dart';
import 'package:simplecloudnotifier/pages/channel_message_view/channel_message_view.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/app_auth.dart';
import 'package:simplecloudnotifier/state/app_settings.dart';
import 'package:simplecloudnotifier/state/application_log.dart';
import 'package:simplecloudnotifier/state/scn_data_cache.dart'; import 'package:simplecloudnotifier/state/scn_data_cache.dart';
import 'package:simplecloudnotifier/utils/navi.dart'; import 'package:simplecloudnotifier/utils/navi.dart';
import 'package:simplecloudnotifier/utils/toaster.dart'; import 'package:simplecloudnotifier/utils/toaster.dart';
class ChannelListItem extends StatefulWidget { enum ChannelListItemMode {
static final _dateFormat = DateFormat('yyyy-MM-dd kk:mm'); Messages,
Extended,
}
class ChannelListItem extends StatefulWidget {
const ChannelListItem({ const ChannelListItem({
required this.channel, required this.channel,
required this.onPressed, required this.onChannelListReloadTrigger,
required this.onSubscriptionChanged,
required this.subscription, required this.subscription,
required this.mode,
super.key, super.key,
}); });
final Channel channel; final Channel channel;
final Subscription? subscription; final Subscription? subscription;
final Null Function() onPressed; final void Function() onChannelListReloadTrigger;
final ChannelListItemMode mode;
final void Function(String, Subscription?) onSubscriptionChanged;
@override @override
State<ChannelListItem> createState() => _ChannelListItemState(); State<ChannelListItem> createState() => _ChannelListItemState();
@ -41,11 +48,11 @@ class _ChannelListItemState extends State<ChannelListItem> {
final acc = Provider.of<AppAuth>(context, listen: false); 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; lastMessage = SCNDataCache().getMessagesSorted().where((p) => p.channelID == widget.channel.channelID).firstOrNull;
() async { () async {
final (_, channelMessages) = await APIClient.getMessageList(acc, '@start', pageSize: 1, filter: MessageFilter(channelIDs: [widget.channel.channelID])); final (_, channelMessages) = await APIClient.getChannelMessageList(acc, widget.channel.channelID, '@start', pageSize: 1);
setState(() { setState(() {
lastMessage = channelMessages.firstOrNull; lastMessage = channelMessages.firstOrNull;
}); });
@ -55,13 +62,20 @@ class _ChannelListItemState extends State<ChannelListItem> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
//TODO subscription status final dateFormat = context.select<AppSettings, AppSettingsDateFormat>((v) => v.dateFormat).dateOnlyFormat();
return Card.filled( return Card.filled(
margin: EdgeInsets.fromLTRB(0, 4, 0, 4), margin: EdgeInsets.fromLTRB(0, 4, 0, 4),
shape: BeveledRectangleBorder(borderRadius: BorderRadius.circular(0)), shape: BeveledRectangleBorder(borderRadius: BorderRadius.circular(0)),
color: Theme.of(context).cardTheme.color, color: Theme.of(context).cardTheme.color,
child: InkWell( child: InkWell(
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( child: Padding(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
child: Row( child: Row(
@ -81,7 +95,7 @@ class _ChannelListItemState extends State<ChannelListItem> {
), ),
), ),
Text( Text(
(widget.channel.timestampLastSent == null) ? '' : ChannelListItem._dateFormat.format(DateTime.parse(widget.channel.timestampLastSent!).toLocal()), (widget.channel.timestampLastSent == null) ? '' : dateFormat.format(DateTime.parse(widget.channel.timestampLastSent!).toLocal()),
style: const TextStyle(fontSize: 14), style: const TextStyle(fontSize: 14),
), ),
], ],
@ -90,13 +104,8 @@ class _ChannelListItemState extends State<ChannelListItem> {
Row( Row(
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
Expanded( Expanded(child: (widget.mode == ChannelListItemMode.Messages) ? Text(_preformatTitle(lastMessage), style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(160))) : _buildSubscriptionStateText(context)),
child: Text( (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)),
_preformatTitle(lastMessage),
style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(160)),
),
),
Text(widget.channel.messagesSent.toString(), style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
], ],
), ),
], ],
@ -105,11 +114,15 @@ class _ChannelListItemState extends State<ChannelListItem> {
SizedBox(width: 4), SizedBox(width: 4),
GestureDetector( GestureDetector(
onTap: () { onTap: () {
Navi.push(context, () => ChannelMessageViewPage(channel: this.widget.channel)); 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( child: Padding(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
child: Icon(FontAwesomeIcons.solidEnvelopes, color: Theme.of(context).colorScheme.onPrimaryContainer.withAlpha(128), size: 24), 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),
), ),
), ),
], ],
@ -125,14 +138,146 @@ class _ChannelListItemState extends State<ChannelListItem> {
} }
Widget _buildIcon(BuildContext context) { Widget _buildIcon(BuildContext context) {
final acc = AppAuth();
if (widget.subscription == null && widget.channel.ownerUserID == acc.userID) {
// not-subscribed (own channel)
Widget result = Icon(FontAwesomeIcons.solidSquareRss, color: Theme.of(context).colorScheme.outline.withAlpha(75), size: 32);
result = GestureDetector(onTap: () => _subscribe(), child: result);
return result;
} else if (widget.subscription == null) {
// not-subscribed (foreign channel)
Widget result = Icon(FontAwesomeIcons.solidSquareShareNodes, color: Theme.of(context).colorScheme.outline.withAlpha(75), size: 32);
return result;
} else if (widget.subscription!.confirmed && !widget.subscription!.active) {
if (widget.channel.ownerUserID == widget.subscription!.subscriberUserID) {
// inactive (own channel)
Widget result = Icon(FontAwesomeIcons.solidSquareRss, color: Theme.of(context).colorScheme.outline.withAlpha(75), size: 32);
result = GestureDetector(onTap: () => _activate(widget.subscription!), child: result);
return result;
} else {
// inactive (foreign channel)
Widget result = Icon(FontAwesomeIcons.solidSquareShareNodes, color: Theme.of(context).colorScheme.outline.withAlpha(75), size: 32);
result = GestureDetector(onTap: () => _activate(widget.subscription!), child: result);
return result;
}
} else if (widget.subscription!.confirmed && widget.subscription!.active) {
if (widget.channel.ownerUserID == widget.subscription!.subscriberUserID) {
// subscribed+active (own channel)
Widget result = Icon(FontAwesomeIcons.solidSquareRss, color: Theme.of(context).colorScheme.onPrimaryContainer, size: 32);
result = GestureDetector(onTap: () => _unsubscribe(widget.subscription!), child: result);
return result;
} else {
// subscribed+active (foreign channel)
Widget result = Icon(FontAwesomeIcons.solidSquareShareNodes, color: Theme.of(context).colorScheme.onPrimaryContainer, size: 32);
result = GestureDetector(onTap: () => _deactivate(widget.subscription!), child: result);
return result;
}
} else if (!widget.subscription!.confirmed) {
if (widget.channel.ownerUserID == widget.subscription!.subscriberUserID) {
// requested (own channel)
return SizedBox(width: 32, height: 32);
} else {
// requested (foreign channel)
Widget result = Icon(FontAwesomeIcons.solidSquareEnvelope, color: Theme.of(context).colorScheme.tertiary, size: 32);
result = GestureDetector(onTap: () => _unsubscribe(widget.subscription!), child: result);
return result;
}
}
// fallback
return SizedBox(width: 32, height: 32);
}
Widget _buildSubscriptionStateText(BuildContext context) {
if (widget.subscription == null) { if (widget.subscription == null) {
return Icon(FontAwesomeIcons.solidSquareDashed, color: Theme.of(context).colorScheme.outline, size: 32); // not-subscribed return Text("", style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(160)));
} else if (widget.subscription!.confirmed && widget.channel.ownerUserID == widget.subscription!.subscriberUserID) { } else if (widget.subscription!.confirmed && widget.subscription!.active && widget.channel.ownerUserID == widget.subscription!.subscriberUserID) {
return Icon(FontAwesomeIcons.solidSquareRss, color: Theme.of(context).colorScheme.onPrimaryContainer, size: 32); // subscribed (own channel) return Text("subscribed", style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(160)));
} else if (widget.subscription!.confirmed) { } else if (widget.subscription!.confirmed && !widget.subscription!.active && widget.channel.ownerUserID == widget.subscription!.subscriberUserID) {
return Icon(FontAwesomeIcons.solidSquareShareNodes, color: Theme.of(context).colorScheme.onPrimaryContainer, size: 32); // subscribed (foreign channel) return Text("inactive (own channel)", style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(160)));
} else if (widget.subscription!.confirmed && widget.subscription!.active) {
return Text("subscribed & active (foreign channel)", style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(160)));
} else if (widget.subscription!.confirmed && !widget.subscription!.active) {
return Text("subscribed (foreign channel) (inactive)", style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(160)));
} else { } else {
return Icon(FontAwesomeIcons.solidSquareEnvelope, color: Theme.of(context).colorScheme.tertiary, size: 32); // requested 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()) {
try {
await APIClient.deleteSubscription(acc, sub.channelID, sub.subscriptionID);
widget.onChannelListReloadTrigger.call();
widget.onSubscriptionChanged.call(sub.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);
}
}
}
void _deactivate(Subscription sub) async {
final acc = AppAuth();
if (acc.isAuth()) {
try {
var newSub = await APIClient.deactivateSubscription(acc, sub.channelID, sub.subscriptionID);
widget.onChannelListReloadTrigger.call();
widget.onSubscriptionChanged.call(sub.channelID, newSub);
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 _activate(Subscription sub) async {
final acc = AppAuth();
if (acc.isAuth()) {
try {
var newSub = await APIClient.activateSubscription(acc, sub.channelID, sub.subscriptionID);
widget.onChannelListReloadTrigger.call();
widget.onSubscriptionChanged.call(sub.channelID, newSub);
Toaster.success("Success", 'Subscribed to channel');
} catch (exc, trace) {
Toaster.error("Error", 'Failed to subscribe to channel');
ApplicationLog.error('Failed to subscribe to channel: ' + exc.toString(), trace: trace);
}
} }
} }
} }

View File

@ -6,7 +6,7 @@ import 'package:simplecloudnotifier/models/channel.dart';
import 'package:simplecloudnotifier/models/scn_message.dart'; import 'package:simplecloudnotifier/models/scn_message.dart';
import 'package:simplecloudnotifier/pages/message_list/message_list_item.dart'; import 'package:simplecloudnotifier/pages/message_list/message_list_item.dart';
import 'package:simplecloudnotifier/pages/message_view/message_view.dart'; import 'package:simplecloudnotifier/pages/message_view/message_view.dart';
import 'package:simplecloudnotifier/settings/app_settings.dart'; import 'package:simplecloudnotifier/state/app_settings.dart';
import 'package:simplecloudnotifier/state/app_auth.dart'; import 'package:simplecloudnotifier/state/app_auth.dart';
import 'package:simplecloudnotifier/state/application_log.dart'; import 'package:simplecloudnotifier/state/application_log.dart';
import 'package:simplecloudnotifier/state/scn_data_cache.dart'; import 'package:simplecloudnotifier/state/scn_data_cache.dart';
@ -55,7 +55,7 @@ class _ChannelMessageViewPageState extends State<ChannelMessageViewPage> {
} }
try { try {
final (npt, newItems) = await APIClient.getMessageList(acc, thisPageToken, pageSize: cfg.messagePageSize, filter: MessageFilter(channelIDs: [this.widget.channel.channelID])); final (npt, newItems) = await APIClient.getChannelMessageList(acc, this.widget.channel.channelID, thisPageToken, pageSize: cfg.messagePageSize);
SCNDataCache().addToMessageCache(newItems); // no await SCNDataCache().addToMessageCache(newItems); // no await

View File

@ -0,0 +1,159 @@
import 'dart:convert';
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/pages/channel_scanner/channel_scanner_result_channelsubscribe.dart';
import 'package:simplecloudnotifier/pages/channel_scanner/channel_scanner_result_channelview.dart';
import 'package:simplecloudnotifier/pages/channel_scanner/channel_scanner_result_messagesend.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),
if (scanResult == null) ...[
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),
],
Center(
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: 300, minWidth: 300, minHeight: 200),
child: _buildScanResult(context),
),
),
],
),
),
),
);
}
void _handleBarcode(BarcodeCapture barcodes) {
setState(() {
if (barcodes.barcodes.isEmpty) {
scanResult = null;
} else {
scanResult = ScanResult.parse(barcodes.barcodes[0].rawValue ?? '');
print('parsed: ${jsonEncode(barcodes.barcodes[0].rawValue)} as ${scanResult.runtimeType.toString()}');
}
});
}
Widget _buildScanResult(BuildContext context) {
if (scanResult == null) {
return UI.box(
padding: EdgeInsets.fromLTRB(16, 32, 16, 8),
context: context,
child: Center(
child: Column(
spacing: 32,
children: [
Icon(FontAwesomeIcons.solidEmptySet, size: 64, color: Theme.of(context).colorScheme.onPrimaryContainer.withAlpha(48)),
Text("Please scan a Channel QR Code to subscribe to it", style: TextStyle(fontStyle: FontStyle.italic), textAlign: TextAlign.center),
],
),
),
);
}
if (scanResult! is ScanResultMessageSend) {
return UI.box(
padding: EdgeInsets.fromLTRB(16, 8, 16, 8),
context: context,
child: ChannelScannerResultMessageSend(value: scanResult! as ScanResultMessageSend),
);
}
if (scanResult! is ScanResultChannel) {
return UI.box(
padding: EdgeInsets.fromLTRB(16, 8, 16, 8),
context: context,
child: ChannelScannerResultChannelView(value: scanResult! as ScanResultChannel),
);
}
if (scanResult! is ScanResultChannelSubscribe) {
return UI.box(
padding: EdgeInsets.fromLTRB(16, 8, 16, 8),
context: context,
child: ChannelScannerResultChannelSubscribe(value: scanResult! as ScanResultChannelSubscribe),
);
}
if (scanResult! is ScanResultError) {
return UI.box(
padding: EdgeInsets.fromLTRB(16, 32, 16, 8),
context: context,
child: Center(
child: Column(
spacing: 32,
children: [
Icon(FontAwesomeIcons.solidTriangleExclamation, size: 64, color: Colors.red[900]),
Text((scanResult! as ScanResultError).message, textAlign: TextAlign.center),
],
),
),
);
}
return UI.box(
padding: EdgeInsets.fromLTRB(16, 32, 16, 8),
context: context,
child: Center(
child: Column(
spacing: 32,
children: [
Icon(FontAwesomeIcons.solidTriangleExclamation, size: 64, color: Colors.red[900]),
Text("Please scan a Channel QR Code to subscribe to it", style: TextStyle(fontStyle: FontStyle.italic), textAlign: TextAlign.center),
],
),
),
);
}
}

View File

@ -0,0 +1,203 @@
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:provider/provider.dart';
import 'package:simplecloudnotifier/api/api_client.dart';
import 'package:simplecloudnotifier/components/error_display/error_display.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_view/channel_view.dart';
import 'package:simplecloudnotifier/state/app_auth.dart';
import 'package:simplecloudnotifier/state/application_log.dart';
import 'package:simplecloudnotifier/utils/navi.dart';
import 'package:simplecloudnotifier/utils/toaster.dart';
import 'package:simplecloudnotifier/utils/ui.dart';
class ChannelScannerResultChannelSubscribe extends StatefulWidget {
final ScanResultChannelSubscribe value;
const ChannelScannerResultChannelSubscribe({required this.value}) : super();
@override
State<ChannelScannerResultChannelSubscribe> createState() => _ChannelScannerResultChannelSubscribeState();
}
class _ChannelScannerResultChannelSubscribeState extends State<ChannelScannerResultChannelSubscribe> {
Future<(ChannelPreview, UserPreview)?> _fetchDataFuture;
_ChannelScannerResultChannelSubscribeState() : _fetchDataFuture = Future.value(null); // Initial dummy future
Subscription? overrideSubscription = null;
@override
void initState() {
super.initState();
final auth = Provider.of<AppAuth>(context, listen: false);
setState(() {
_fetchDataFuture = _fetchData(auth);
});
}
Future<(ChannelPreview, UserPreview)?> _fetchData(AppAuth auth) async {
ChannelPreview? channel = null;
try {
channel = await APIClient.getChannelPreview(auth, widget.value.channelID);
} catch (e, stackTrace) {
Toaster.error("Error", 'Failed to fetch channel preview: ${e.toString()}');
ApplicationLog.error('Failed to fetch channel (preview) for ${widget.value.channelID}', trace: stackTrace);
return null;
}
UserPreview? user = null;
try {
user = await APIClient.getUserPreview(auth, widget.value.ownerUserID);
} catch (e, stackTrace) {
Toaster.error("Error", 'Failed to fetch user preview: ${e.toString()}');
ApplicationLog.error('Failed to fetch user (preview) for ${widget.value.ownerUserID}', trace: stackTrace);
return null;
}
return (channel, user);
}
@override
Widget build(BuildContext context) {
return FutureBuilder<(ChannelPreview, UserPreview)?>(
future: _fetchDataFuture,
builder: (context, snapshot) {
final auth = Provider.of<AppAuth>(context, listen: false);
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return ErrorDisplay(errorMessage: '${snapshot.error}');
}
if (snapshot.data == null) {
return Column(
spacing: 32,
children: [
Icon(FontAwesomeIcons.solidTriangleExclamation, size: 64, color: Colors.red[900]),
Text("Failed to parse QR", textAlign: TextAlign.center),
],
);
}
final (channel, user) = snapshot.data!;
final sub = overrideSubscription ?? channel.subscription;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text("SCN Channel", style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 20), textAlign: TextAlign.center),
const SizedBox(height: 16),
Row(
children: [
ConstrainedBox(child: Text("Name: ", style: const TextStyle(fontWeight: FontWeight.bold)), constraints: const BoxConstraints(minWidth: 100)),
Expanded(child: SingleChildScrollView(child: Text(channel.displayName), scrollDirection: Axis.horizontal)),
],
),
Row(
children: [
ConstrainedBox(child: Text("InternalName: ", style: const TextStyle(fontWeight: FontWeight.bold)), constraints: const BoxConstraints(minWidth: 100)),
Expanded(child: SingleChildScrollView(child: Text(channel.internalName, style: const TextStyle(fontStyle: FontStyle.italic)), scrollDirection: Axis.horizontal)),
],
),
Row(
children: [
ConstrainedBox(child: Text("ChannelID: ", style: const TextStyle(fontWeight: FontWeight.bold)), constraints: const BoxConstraints(minWidth: 100)),
Expanded(child: SingleChildScrollView(child: Text(channel.channelID, style: const TextStyle(fontStyle: FontStyle.italic)), scrollDirection: Axis.horizontal)),
],
),
Row(
children: [
ConstrainedBox(child: Text("Messages: ", style: const TextStyle(fontWeight: FontWeight.bold)), constraints: const BoxConstraints(minWidth: 100)),
Expanded(child: SingleChildScrollView(child: Text(channel.messagesSent.toString()), scrollDirection: Axis.horizontal)),
],
),
if (channel.descriptionName != null && channel.descriptionName!.isNotEmpty)
Row(
children: [
ConstrainedBox(child: Text("Description:", style: const TextStyle(fontWeight: FontWeight.bold)), constraints: const BoxConstraints(minWidth: 100)),
Expanded(child: SingleChildScrollView(child: Text(channel.descriptionName!), scrollDirection: Axis.horizontal)),
],
),
const SizedBox(height: 16),
Row(
children: [
ConstrainedBox(child: Text("Owner:", style: const TextStyle(fontWeight: FontWeight.bold)), constraints: const BoxConstraints(minWidth: 100)),
Expanded(child: SingleChildScrollView(child: Text((user.username ?? user.userID) + ((auth.userID != null && auth.userID! == user.userID) ? "\n(you)" : "")), scrollDirection: Axis.horizontal)),
],
),
Row(
children: [
ConstrainedBox(child: Text("UserID:", style: const TextStyle(fontWeight: FontWeight.bold)), constraints: const BoxConstraints(minWidth: 100)),
Expanded(child: SingleChildScrollView(child: Text(user.userID, style: const TextStyle(fontStyle: FontStyle.italic)), scrollDirection: Axis.horizontal)),
],
),
const SizedBox(height: 16),
Row(
children: [
ConstrainedBox(child: Text("Status:", style: const TextStyle(fontWeight: FontWeight.bold)), constraints: const BoxConstraints(minWidth: 100)),
Expanded(child: SingleChildScrollView(child: Text(_formatSubscriptionStatus(sub)), scrollDirection: Axis.horizontal)),
],
),
const SizedBox(height: 48),
if (sub == null)
UI.button(
text: 'Request Subscription',
onPressed: _onSubscribe,
color: Theme.of(context).colorScheme.primary,
textColor: Theme.of(context).colorScheme.onPrimary,
),
if (sub != null && sub.confirmed)
UI.button(
text: 'Go to channel',
onPressed: () {
Navi.pushOnRoot(context, () => ChannelViewPage(channelID: widget.value.channelID, preloadedData: null, needsReload: null));
},
color: Theme.of(context).colorScheme.primary,
textColor: Theme.of(context).colorScheme.onPrimary,
),
],
);
},
);
}
void _onSubscribe() async {
final auth = Provider.of<AppAuth>(context, listen: false);
try {
var sub = await APIClient.subscribeToChannelbyID(auth, widget.value.channelID, subscribeKey: widget.value.subscribeKey);
if (sub.confirmed) {
Toaster.success("Success", "Subscription request sent and auto-confirmed");
} else {
Toaster.success("Success", "Subscription request sent - pending confirmation");
}
setState(() {
overrideSubscription = sub;
});
} catch (e) {
Toaster.error("Error", 'Failed to send subscription-request: ${e.toString()}');
}
}
String _formatSubscriptionStatus(Subscription? sub) {
if (sub == null) {
return "Not Subscribed";
} else if (sub.confirmed) {
if (sub.active) {
return "Already Subscribed";
} else {
return "Already Subscribed (inactive)";
}
} else {
return "Unconfirmed Subscription";
}
}
}

View File

@ -0,0 +1,162 @@
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:provider/provider.dart';
import 'package:simplecloudnotifier/api/api_client.dart';
import 'package:simplecloudnotifier/components/error_display/error_display.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/state/app_auth.dart';
import 'package:simplecloudnotifier/state/application_log.dart';
import 'package:simplecloudnotifier/utils/toaster.dart';
class ChannelScannerResultChannelView extends StatefulWidget {
final ScanResultChannel value;
const ChannelScannerResultChannelView({required this.value}) : super();
@override
State<ChannelScannerResultChannelView> createState() => _ChannelScannerResultChannelViewState();
}
class _ChannelScannerResultChannelViewState extends State<ChannelScannerResultChannelView> {
Future<(ChannelPreview, UserPreview)?> _fetchDataFuture;
_ChannelScannerResultChannelViewState() : _fetchDataFuture = Future.value(null); // Initial dummy future
@override
void initState() {
super.initState();
final auth = Provider.of<AppAuth>(context, listen: false);
setState(() {
_fetchDataFuture = _fetchData(auth);
});
}
Future<(ChannelPreview, UserPreview)?> _fetchData(AppAuth auth) async {
ChannelPreview? channel = null;
try {
channel = await APIClient.getChannelPreview(auth, widget.value.channelID);
} catch (e, stackTrace) {
Toaster.error("Error", 'Failed to fetch channel preview: ${e.toString()}');
ApplicationLog.error('Failed to fetch channel (preview) for ${widget.value.channelID}', trace: stackTrace);
return null;
}
UserPreview? user = null;
try {
user = await APIClient.getUserPreview(auth, widget.value.ownerUserID);
} catch (e, stackTrace) {
Toaster.error("Error", 'Failed to fetch user preview: ${e.toString()}');
ApplicationLog.error('Failed to fetch user (preview) for ${widget.value.ownerUserID}', trace: stackTrace);
return null;
}
return (channel, user);
}
@override
Widget build(BuildContext context) {
return FutureBuilder<(ChannelPreview, UserPreview)?>(
future: _fetchDataFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return ErrorDisplay(errorMessage: '${snapshot.error}');
}
if (snapshot.data == null) {
return Column(
spacing: 32,
children: [
Icon(FontAwesomeIcons.solidTriangleExclamation, size: 64, color: Colors.red[900]),
Text("Failed to parse QR", textAlign: TextAlign.center),
],
);
}
final (channel, user) = snapshot.data!;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text("SCN Channel", style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 20), textAlign: TextAlign.center),
const SizedBox(height: 16),
Row(
children: [
ConstrainedBox(child: Text("Name: ", style: const TextStyle(fontWeight: FontWeight.bold)), constraints: const BoxConstraints(minWidth: 100)),
Expanded(child: SingleChildScrollView(child: Text(channel.displayName), scrollDirection: Axis.horizontal)),
],
),
Row(
children: [
ConstrainedBox(child: Text("InternalName: ", style: const TextStyle(fontWeight: FontWeight.bold)), constraints: const BoxConstraints(minWidth: 100)),
Expanded(child: SingleChildScrollView(child: Text(channel.internalName, style: const TextStyle(fontStyle: FontStyle.italic)), scrollDirection: Axis.horizontal)),
],
),
Row(
children: [
ConstrainedBox(child: Text("ChannelID: ", style: const TextStyle(fontWeight: FontWeight.bold)), constraints: const BoxConstraints(minWidth: 100)),
Expanded(child: SingleChildScrollView(child: Text(channel.channelID, style: const TextStyle(fontStyle: FontStyle.italic)), scrollDirection: Axis.horizontal)),
],
),
Row(
children: [
ConstrainedBox(child: Text("Messages: ", style: const TextStyle(fontWeight: FontWeight.bold)), constraints: const BoxConstraints(minWidth: 100)),
Expanded(child: SingleChildScrollView(child: Text(channel.messagesSent.toString()), scrollDirection: Axis.horizontal)),
],
),
if (channel.descriptionName != null && channel.descriptionName!.isNotEmpty)
Row(
children: [
ConstrainedBox(child: Text("Description:", style: const TextStyle(fontWeight: FontWeight.bold)), constraints: const BoxConstraints(minWidth: 100)),
Expanded(child: SingleChildScrollView(child: Text(channel.descriptionName!), scrollDirection: Axis.horizontal)),
],
),
const SizedBox(height: 16),
Row(
children: [
ConstrainedBox(child: Text("Owner:", style: const TextStyle(fontWeight: FontWeight.bold)), constraints: const BoxConstraints(minWidth: 100)),
Expanded(child: SingleChildScrollView(child: Text(user.username ?? user.userID), scrollDirection: Axis.horizontal)),
],
),
Row(
children: [
ConstrainedBox(child: Text("UserID:", style: const TextStyle(fontWeight: FontWeight.bold)), constraints: const BoxConstraints(minWidth: 100)),
Expanded(child: SingleChildScrollView(child: Text(user.userID, style: const TextStyle(fontStyle: FontStyle.italic)), scrollDirection: Axis.horizontal)),
],
),
const SizedBox(height: 16),
Row(
children: [
ConstrainedBox(child: Text("Status:", style: const TextStyle(fontWeight: FontWeight.bold)), constraints: const BoxConstraints(minWidth: 100)),
Expanded(child: SingleChildScrollView(child: Text(_formatSubscriptionStatus(channel.subscription)), scrollDirection: Axis.horizontal)),
],
),
const SizedBox(height: 48),
Text('QR Code contains no subscription-key\nCannot subscribe to channel', textAlign: TextAlign.center, style: const TextStyle(fontStyle: FontStyle.italic)),
],
);
},
);
}
String _formatSubscriptionStatus(Subscription? sub) {
if (sub == null) {
return "Not Subscribed";
} else if (sub.confirmed) {
if (sub.active) {
return "Already Subscribed";
} else {
return "Already Subscribed (inactive)";
}
} else {
return "Unconfirmed Subscription";
}
}
}

View File

@ -0,0 +1,243 @@
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:provider/provider.dart';
import 'package:simplecloudnotifier/api/api_client.dart';
import 'package:simplecloudnotifier/components/error_display/error_display.dart';
import 'package:simplecloudnotifier/models/keytoken.dart';
import 'package:simplecloudnotifier/models/scan_result.dart';
import 'package:simplecloudnotifier/models/user.dart';
import 'package:simplecloudnotifier/state/app_auth.dart';
import 'package:simplecloudnotifier/state/application_log.dart';
import 'package:simplecloudnotifier/utils/toaster.dart';
import 'package:simplecloudnotifier/utils/ui.dart';
import 'package:url_launcher/url_launcher.dart';
class ChannelScannerResultMessageSend extends StatefulWidget {
final ScanResultMessageSend value;
const ChannelScannerResultMessageSend({required this.value}) : super();
@override
State<ChannelScannerResultMessageSend> createState() => _ChannelScannerResultMessageSendState();
}
class _ChannelScannerResultMessageSendState extends State<ChannelScannerResultMessageSend> {
Future<(UserPreview, KeyTokenPreview?)?> _fetchDataFuture;
_ChannelScannerResultMessageSendState() : _fetchDataFuture = Future.value(null); // Initial dummy future
late TextEditingController _ctrlMessage;
@override
void initState() {
super.initState();
_ctrlMessage = TextEditingController();
final auth = Provider.of<AppAuth>(context, listen: false);
setState(() {
_fetchDataFuture = _fetchData(auth);
});
}
@override
void dispose() {
_ctrlMessage.dispose();
super.dispose();
}
Future<(UserPreview, KeyTokenPreview?)?> _fetchData(AppAuth auth) async {
UserPreview? user = null;
try {
user = await APIClient.getUserPreview(auth, widget.value.userID);
} catch (e, stackTrace) {
Toaster.error("Error", 'Failed to fetch user preview: ${e.toString()}');
ApplicationLog.error('Failed to fetch user (preview) for ${widget.value.userID}', trace: stackTrace);
return null;
}
KeyTokenPreview? key = null;
if (widget.value.userKey != null) {
try {
key = await APIClient.getKeyTokenPreviewByToken(auth, widget.value.userKey!);
} catch (e, stackTrace) {
Toaster.error("Error", 'Failed to fetch keytoken preview: ${e.toString()}');
ApplicationLog.error('Failed to fetch keytoken (preview) for ${widget.value.userID}', trace: stackTrace);
return null;
}
}
return (user, key);
}
@override
Widget build(BuildContext context) {
return FutureBuilder<(UserPreview, KeyTokenPreview?)?>(
future: _fetchDataFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return ErrorDisplay(errorMessage: '${snapshot.error}');
}
if (snapshot.data == null) {
return Column(
spacing: 32,
children: [
Icon(FontAwesomeIcons.solidTriangleExclamation, size: 64, color: Colors.red[900]),
Text("Failed to parse QR", textAlign: TextAlign.center),
],
);
}
final (user, key) = snapshot.data!;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text((widget.value.userKey == null) ? "SCN User" : "SCN User & Key", style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 20), textAlign: TextAlign.center),
const SizedBox(height: 16),
if (user.username != null)
Row(
children: [
ConstrainedBox(child: Text("Name: ", style: const TextStyle(fontWeight: FontWeight.bold)), constraints: const BoxConstraints(minWidth: 100)),
Expanded(child: SingleChildScrollView(child: Text(user.username!), scrollDirection: Axis.horizontal)),
],
),
Row(
children: [
ConstrainedBox(child: Text("UserID:", style: const TextStyle(fontWeight: FontWeight.bold)), constraints: const BoxConstraints(minWidth: 100)),
Expanded(child: SingleChildScrollView(child: Text(user.userID, style: const TextStyle(fontStyle: FontStyle.italic)), scrollDirection: Axis.horizontal)),
],
),
const SizedBox(height: 16),
if (key != null) ...[
Row(
children: [
ConstrainedBox(child: Text("KeyID:", style: const TextStyle(fontWeight: FontWeight.bold)), constraints: const BoxConstraints(minWidth: 100)),
Expanded(child: SingleChildScrollView(child: Text(key.keytokenID, style: const TextStyle(fontStyle: FontStyle.italic)), scrollDirection: Axis.horizontal)),
],
),
Row(
children: [
ConstrainedBox(child: Text("KeyName:", style: const TextStyle(fontWeight: FontWeight.bold)), constraints: const BoxConstraints(minWidth: 100)),
Expanded(child: SingleChildScrollView(child: Text(key.name), scrollDirection: Axis.horizontal)),
],
),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ConstrainedBox(child: Text("Permissions:", style: const TextStyle(fontWeight: FontWeight.bold)), constraints: const BoxConstraints(minWidth: 100)),
Expanded(
child: SingleChildScrollView(
child: Text(_formatPermissions(key.permissions) + "\n" + (key.allChannels ? "(all channels)" : '(${key.channels.length} channels)')),
scrollDirection: Axis.horizontal,
),
),
],
),
],
const SizedBox(height: 16),
if (widget.value.userKey == null)
Text(
'QR Code contains no key\nCannot send messages',
textAlign: TextAlign.center,
style: const TextStyle(fontStyle: FontStyle.italic),
),
if (widget.value.userKey != null) ..._buildSend(context),
],
);
},
);
}
List<Widget> _buildSend(BuildContext context) {
return [
FractionallySizedBox(
widthFactor: 1.0,
child: TextField(
controller: _ctrlMessage,
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: 'Text',
),
),
),
const SizedBox(height: 4),
Row(
children: [
Expanded(
child: UI.button(
text: 'Send Message',
onPressed: _onSend,
color: Theme.of(context).colorScheme.primary,
textColor: Theme.of(context).colorScheme.onPrimary,
),
),
const SizedBox(width: 8),
UI.buttonIconOnly(
icon: FontAwesomeIcons.earthAmericas,
onPressed: _onOpenWeb,
square: true,
color: Theme.of(context).colorScheme.secondary,
iconColor: Theme.of(context).colorScheme.onSecondary,
),
],
),
];
}
void _onSend() async {
if (_ctrlMessage.text.isEmpty) {
Toaster.error("Error", 'Please enter a message');
return;
}
if (widget.value.userKey == null) return;
try {
await APIClient.sendMessage(widget.value.userID, widget.value.userKey!, _ctrlMessage.text);
Toaster.success("Success", 'Message sent');
setState(() {
_ctrlMessage.clear();
});
} catch (e, stackTrace) {
Toaster.error("Error", 'Failed to send message: ${e.toString()}');
ApplicationLog.error('Failed to send message', trace: stackTrace);
}
}
void _onOpenWeb() async {
try {
final Uri uri = Uri.parse(widget.value.url);
ApplicationLog.debug('Opening URL: [ ${uri.toString()} ]');
if (await canLaunchUrl(uri)) {
await launchUrl(uri);
} else {
Toaster.error("Error", 'Cannot open URL on this system');
}
} catch (exc, trace) {
ApplicationLog.error('Failed to open URL: ' + exc.toString(), additional: 'URL: ${widget.value.url}', trace: trace);
}
}
String _formatPermissions(String v) {
var splt = v.split(';');
if (splt.length == 0) return "None";
List<String> result = [];
if (splt.contains("A")) result.add(" - Admin");
if (splt.contains("UR")) result.add(" - Read Account");
if (splt.contains("CR")) result.add(" - Read Messages");
if (splt.contains("CS")) result.add(" - Send Messages");
return result.join("\n");
}
}

View File

@ -3,15 +3,20 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:qr_flutter/qr_flutter.dart'; import 'package:qr_flutter/qr_flutter.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
import 'package:simplecloudnotifier/api/api_client.dart'; import 'package:simplecloudnotifier/api/api_client.dart';
import 'package:simplecloudnotifier/components/error_display/error_display.dart';
import 'package:simplecloudnotifier/components/layout/scaffold.dart'; import 'package:simplecloudnotifier/components/layout/scaffold.dart';
import 'package:simplecloudnotifier/models/channel.dart'; import 'package:simplecloudnotifier/models/channel.dart';
import 'package:simplecloudnotifier/models/scan_result.dart';
import 'package:simplecloudnotifier/models/subscription.dart'; import 'package:simplecloudnotifier/models/subscription.dart';
import 'package:simplecloudnotifier/models/user.dart'; import 'package:simplecloudnotifier/models/user.dart';
import 'package:simplecloudnotifier/pages/channel_message_view/channel_message_view.dart'; import 'package:simplecloudnotifier/pages/channel_message_view/channel_message_view.dart';
import 'package:simplecloudnotifier/pages/filtered_message_view/filtered_message_view.dart';
import 'package:simplecloudnotifier/pages/subscription_view/subscription_view.dart';
import 'package:simplecloudnotifier/state/app_auth.dart'; import 'package:simplecloudnotifier/state/app_auth.dart';
import 'package:simplecloudnotifier/state/app_bar_state.dart'; import 'package:simplecloudnotifier/state/app_bar_state.dart';
import 'package:simplecloudnotifier/state/application_log.dart'; import 'package:simplecloudnotifier/state/application_log.dart';
import 'package:simplecloudnotifier/types/immediate_future.dart'; import 'package:simplecloudnotifier/types/immediate_future.dart';
import 'package:simplecloudnotifier/utils/dialogs.dart';
import 'package:simplecloudnotifier/utils/navi.dart'; import 'package:simplecloudnotifier/utils/navi.dart';
import 'package:simplecloudnotifier/utils/toaster.dart'; import 'package:simplecloudnotifier/utils/toaster.dart';
import 'package:simplecloudnotifier/utils/ui.dart'; import 'package:simplecloudnotifier/utils/ui.dart';
@ -39,9 +44,9 @@ enum EditState { none, editing, saving }
enum ChannelViewPageInitState { loading, okay, error } enum ChannelViewPageInitState { loading, okay, error }
class _ChannelViewPageState extends State<ChannelViewPage> { class _ChannelViewPageState extends State<ChannelViewPage> {
late ImmediateFuture<String?> _futureSubscribeKey; ImmediateFuture<String?> _futureSubscribeKey = ImmediateFuture.ofPending();
late ImmediateFuture<List<(Subscription, UserPreview?)>> _futureSubscriptions; ImmediateFuture<List<(Subscription, UserPreview?)>> _futureSubscriptions = ImmediateFuture.ofPending();
late ImmediateFuture<UserPreview> _futureOwner; ImmediateFuture<UserPreview> _futureOwner = ImmediateFuture.ofPending();
final TextEditingController _ctrlDisplayName = TextEditingController(); final TextEditingController _ctrlDisplayName = TextEditingController();
final TextEditingController _ctrlDescriptionName = TextEditingController(); final TextEditingController _ctrlDescriptionName = TextEditingController();
@ -63,30 +68,36 @@ class _ChannelViewPageState extends State<ChannelViewPage> {
@override @override
void initState() { void initState() {
_initStateAsync(); _initStateAsync(true);
super.initState(); super.initState();
} }
@override Future<void> _initStateAsync(bool usePreload) async {
void _initStateAsync() async {
final userAcc = Provider.of<AppAuth>(context, listen: false); final userAcc = Provider.of<AppAuth>(context, listen: false);
if (widget.preloadedData != null) { if (widget.preloadedData != null && usePreload) {
channelPreview = widget.preloadedData!.$1.toPreview();
channel = widget.preloadedData!.$1; channel = widget.preloadedData!.$1;
subscription = widget.preloadedData!.$2; subscription = widget.preloadedData!.$2;
channelPreview = widget.preloadedData!.$1.toPreview(widget.preloadedData!.$2);
} else { } else {
try { try {
var p = await APIClient.getChannelPreview(userAcc, widget.channelID); var p = await APIClient.getChannelPreview(userAcc, widget.channelID);
channelPreview = p; setState(() {
channelPreview = p;
subscription = p.subscription;
});
if (p.ownerUserID == userAcc.userID) { if (p.ownerUserID == userAcc.userID) {
var r = await APIClient.getChannel(userAcc, widget.channelID); var r = await APIClient.getChannel(userAcc, widget.channelID);
channel = r.channel; setState(() {
subscription = r.subscription; channel = r.channel;
subscription = r.subscription;
});
} else { } else {
channel = null; setState(() {
subscription = null; //TODO get own subscription on this channel, even though its foreign channel channel = null;
});
} }
} catch (exc, trace) { } catch (exc, trace) {
ApplicationLog.error('Failed to load data: ' + exc.toString(), trace: trace); ApplicationLog.error('Failed to load data: ' + exc.toString(), trace: trace);
@ -97,32 +108,34 @@ class _ChannelViewPageState extends State<ChannelViewPage> {
} }
} }
this.loadingState = ChannelViewPageInitState.okay; setState(() {
this.loadingState = ChannelViewPageInitState.okay;
assert(channelPreview != null); assert(channelPreview != null);
if (this.channelPreview!.ownerUserID == userAcc.userID) { if (this.channelPreview!.ownerUserID == userAcc.userID) {
if (this.channel != null && this.channel!.subscribeKey != null) { if (this.channel != null && this.channel!.subscribeKey != null) {
_futureSubscribeKey = ImmediateFuture<String?>.ofValue(this.channel!.subscribeKey); _futureSubscribeKey = ImmediateFuture<String?>.ofValue(this.channel!.subscribeKey);
} else {
_futureSubscribeKey = ImmediateFuture<String?>.ofFuture(_getSubscribeKey(userAcc));
}
_futureSubscriptions = ImmediateFuture<List<(Subscription, UserPreview?)>>.ofFuture(_listSubscriptions(userAcc));
} else { } else {
_futureSubscribeKey = ImmediateFuture<String?>.ofFuture(_getSubscribeKey(userAcc)); _futureSubscribeKey = ImmediateFuture<String?>.ofValue(null);
_futureSubscriptions = ImmediateFuture<List<(Subscription, UserPreview?)>>.ofValue([]);
} }
_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) { if (this.channelPreview!.ownerUserID == userAcc.userID) {
var cacheUser = userAcc.getUserOrNull(); var cacheUser = userAcc.getUserOrNull();
if (cacheUser != null) { if (cacheUser != null) {
_futureOwner = ImmediateFuture<UserPreview>.ofValue(cacheUser.toPreview()); _futureOwner = ImmediateFuture<UserPreview>.ofValue(cacheUser.toPreview());
} else {
_futureOwner = ImmediateFuture<UserPreview>.ofFuture(_getOwner(userAcc));
}
} else { } else {
_futureOwner = ImmediateFuture<UserPreview>.ofFuture(_getOwner(userAcc)); _futureOwner = ImmediateFuture<UserPreview>.ofFuture(APIClient.getUserPreview(userAcc, this.channelPreview!.ownerUserID));
} }
} else { });
_futureOwner = ImmediateFuture<UserPreview>.ofFuture(APIClient.getUserPreview(userAcc, this.channelPreview!.ownerUserID));
}
} }
@override @override
@ -141,7 +154,7 @@ class _ChannelViewPageState extends State<ChannelViewPage> {
if (loadingState == ChannelViewPageInitState.loading) { if (loadingState == ChannelViewPageInitState.loading) {
child = Center(child: CircularProgressIndicator()); child = Center(child: CircularProgressIndicator());
} else if (loadingState == ChannelViewPageInitState.error) { } else if (loadingState == ChannelViewPageInitState.error) {
child = Center(child: Text('Error: ' + errorMessage)); //TODO better error child = ErrorDisplay(errorMessage: errorMessage);
} else if (loadingState == ChannelViewPageInitState.okay && channelPreview!.ownerUserID == userAcc.userID) { } else if (loadingState == ChannelViewPageInitState.okay && channelPreview!.ownerUserID == userAcc.userID) {
child = _buildOwnedChannelView(context, this.channel!); child = _buildOwnedChannelView(context, this.channel!);
} else { } else {
@ -157,6 +170,7 @@ class _ChannelViewPageState extends State<ChannelViewPage> {
} }
Widget _buildOwnedChannelView(BuildContext context, Channel channel) { Widget _buildOwnedChannelView(BuildContext context, Channel channel) {
final userAccUserID = context.select<AppAuth, String?>((v) => v.userID);
final isSubscribed = (subscription != null && subscription!.confirmed); final isSubscribed = (subscription != null && subscription!.confirmed);
return SingleChildScrollView( return SingleChildScrollView(
@ -186,7 +200,8 @@ class _ChannelViewPageState extends State<ChannelViewPage> {
icon: FontAwesomeIcons.solidDiagramSubtask, icon: FontAwesomeIcons.solidDiagramSubtask,
title: 'Subscription (own)', title: 'Subscription (own)',
values: [_formatSubscriptionStatus(this.subscription)], values: [_formatSubscriptionStatus(this.subscription)],
iconActions: isSubscribed ? [(FontAwesomeIcons.solidSquareXmark, _unsubscribe)] : [(FontAwesomeIcons.solidSquareRss, _subscribe)], iconActions: isSubscribed ? [(FontAwesomeIcons.solidSquareXmark, null, _unsubscribe)] : [(FontAwesomeIcons.solidSquareRss, null, _subscribe)],
mainAction: () => Navi.push(context, () => SubscriptionViewPage(subscriptionID: subscription!.subscriptionID, preloadedData: (subscription, null, null, null), needsReload: widget.needsReload)),
), ),
_buildForeignSubscriptions(context), _buildForeignSubscriptions(context),
_buildOwnerCard(context, true), _buildOwnerCard(context, true),
@ -199,6 +214,7 @@ class _ChannelViewPageState extends State<ChannelViewPage> {
Navi.push(context, () => ChannelMessageViewPage(channel: channel)); Navi.push(context, () => ChannelMessageViewPage(channel: channel));
}, },
), ),
if (channel.ownerUserID == userAccUserID) UI.button(text: "Delete Channel", onPressed: () {/*TODO*/}, color: Colors.red[900]),
], ],
), ),
), ),
@ -206,7 +222,53 @@ class _ChannelViewPageState extends State<ChannelViewPage> {
} }
Widget _buildForeignChannelView(BuildContext context, ChannelPreview channel) { Widget _buildForeignChannelView(BuildContext context, ChannelPreview channel) {
final isSubscribed = (subscription != null && subscription!.confirmed); Widget subCard;
if (subscription != null && subscription!.confirmed && subscription!.active) {
subCard = UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidDiagramSubtask,
title: 'Subscription (foreign)',
values: [_formatSubscriptionStatus(subscription)],
iconActions: [(FontAwesomeIcons.solidSquareXmark, null, _deactivate)],
mainAction: () => Navi.push(context, () => SubscriptionViewPage(subscriptionID: subscription!.subscriptionID, preloadedData: (subscription, null, null, null), needsReload: widget.needsReload)),
);
} else if (subscription != null && subscription!.confirmed && !subscription!.active) {
subCard = UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidDiagramSubtask,
title: 'Subscription (foreign)',
values: [_formatSubscriptionStatus(subscription)],
iconActions: [(FontAwesomeIcons.solidSquareXmark, Colors.red[900], () => _unsubscribe(confirm: 'Really (permantenly) delete your subscription to this channel?')), (FontAwesomeIcons.solidSquareRss, null, _activate)],
mainAction: () => Navi.push(context, () => SubscriptionViewPage(subscriptionID: subscription!.subscriptionID, preloadedData: (subscription, null, null, null), needsReload: widget.needsReload)),
);
} else if (subscription != null && !subscription!.confirmed) {
subCard = UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidDiagramSubtask,
title: 'Subscription (foreign)',
values: [_formatSubscriptionStatus(subscription)],
iconActions: [(FontAwesomeIcons.solidSquareXmark, Colors.red[900], () => _unsubscribe(confirm: 'Really withdraw your subscription-request to this channel?'))],
mainAction: () => Navi.push(context, () => SubscriptionViewPage(subscriptionID: subscription!.subscriptionID, preloadedData: (subscription, null, null, null), needsReload: widget.needsReload)),
);
} else if (subscription == null) {
subCard = UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidDiagramSubtask,
title: 'Subscription (foreign)',
values: [_formatSubscriptionStatus(subscription)],
iconActions: [(FontAwesomeIcons.solidSquareRss, null, _subscribe)],
mainAction: () => Navi.push(context, () => SubscriptionViewPage(subscriptionID: subscription!.subscriptionID, preloadedData: (subscription, null, null, null), needsReload: widget.needsReload)),
);
} else {
subCard = UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidDiagramSubtask,
title: 'Subscription (foreign)',
values: [_formatSubscriptionStatus(subscription)],
mainAction: () => Navi.push(context, () => SubscriptionViewPage(subscriptionID: subscription!.subscriptionID, preloadedData: (subscription, null, null, null), needsReload: widget.needsReload)),
);
}
return SingleChildScrollView( return SingleChildScrollView(
child: Padding( child: Padding(
@ -229,15 +291,16 @@ class _ChannelViewPageState extends State<ChannelViewPage> {
), ),
_buildDisplayNameCard(context, false), _buildDisplayNameCard(context, false),
_buildDescriptionNameCard(context, false), _buildDescriptionNameCard(context, false),
UI.metaCard( subCard,
context: context,
icon: FontAwesomeIcons.solidDiagramSubtask,
title: 'Subscription (own)',
values: [_formatSubscriptionStatus(subscription)],
iconActions: isSubscribed ? [(FontAwesomeIcons.solidSquareXmark, _unsubscribe)] : [(FontAwesomeIcons.solidSquareRss, _subscribe)],
),
_buildForeignSubscriptions(context), _buildForeignSubscriptions(context),
_buildOwnerCard(context, false), _buildOwnerCard(context, false),
UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidEnvelope,
title: 'Messages',
values: [channel.messagesSent.toString()],
mainAction: (subscription != null && subscription!.confirmed) ? () => Navi.push(context, () => FilteredMessageViewPage(title: channel.displayName, filter: MessageFilter(channelIDs: [channel.channelID]))) : null,
),
], ],
), ),
), ),
@ -258,7 +321,8 @@ class _ChannelViewPageState extends State<ChannelViewPage> {
icon: FontAwesomeIcons.solidDiagramSuccessor, icon: FontAwesomeIcons.solidDiagramSuccessor,
title: 'Subscription (' + (user?.username ?? user?.userID ?? 'other') + ')', title: 'Subscription (' + (user?.username ?? user?.userID ?? 'other') + ')',
values: [_formatSubscriptionStatus(sub)], values: [_formatSubscriptionStatus(sub)],
iconActions: _getForeignSubActions(sub), iconActions: _getForeignIncomingSubActions(sub),
mainAction: () => Navi.push(context, () => SubscriptionViewPage(subscriptionID: subscription!.subscriptionID, preloadedData: (subscription, null, null, null), needsReload: widget.needsReload)),
), ),
], ],
); );
@ -296,8 +360,8 @@ class _ChannelViewPageState extends State<ChannelViewPage> {
return FutureBuilder( return FutureBuilder(
future: _futureSubscribeKey.future, future: _futureSubscribeKey.future,
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.hasData && snapshot.data != null) { if (snapshot.hasData) {
var text = 'TODO' + '\n' + channel!.channelID + '\n' + snapshot.data!; //TODO deeplink-y (also perhaps just bas64 everything together?) final text = (snapshot.data == null) ? ScanResult.createChannelQR(channel!) : ScanResult.createChannelSubscribeQR(channel!, snapshot.data!);
return GestureDetector( return GestureDetector(
onTap: () { onTap: () {
Share.share(text, subject: _displayNameOverride ?? channel!.displayName); Share.share(text, subject: _displayNameOverride ?? channel!.displayName);
@ -306,7 +370,7 @@ class _ChannelViewPageState extends State<ChannelViewPage> {
child: QrImageView( child: QrImageView(
data: text, data: text,
version: QrVersions.auto, version: QrVersions.auto,
size: 300.0, size: 265.0,
eyeStyle: QrEyeStyle( eyeStyle: QrEyeStyle(
eyeShape: QrEyeShape.square, eyeShape: QrEyeShape.square,
color: Theme.of(context).textTheme.bodyLarge?.color, color: Theme.of(context).textTheme.bodyLarge?.color,
@ -318,12 +382,6 @@ class _ChannelViewPageState extends State<ChannelViewPage> {
), ),
), ),
); );
} else if (snapshot.hasData && snapshot.data == null) {
return const SizedBox(
width: 300.0,
height: 300.0,
child: Center(child: Icon(FontAwesomeIcons.solidSnake, size: 64)),
);
} else { } else {
return const SizedBox( return const SizedBox(
width: 300.0, width: 300.0,
@ -366,7 +424,7 @@ class _ChannelViewPageState extends State<ChannelViewPage> {
icon: FontAwesomeIcons.solidInputText, icon: FontAwesomeIcons.solidInputText,
title: 'DisplayName', title: 'DisplayName',
values: [_displayNameOverride ?? channelPreview!.displayName], values: [_displayNameOverride ?? channelPreview!.displayName],
iconActions: isOwned ? [(FontAwesomeIcons.penToSquare, _showEditDisplayName)] : [], iconActions: isOwned ? [(FontAwesomeIcons.penToSquare, null, _showEditDisplayName)] : [],
); );
} else if (_editDisplayName == EditState.saving) { } else if (_editDisplayName == EditState.saving) {
return Padding( return Padding(
@ -422,7 +480,7 @@ class _ChannelViewPageState extends State<ChannelViewPage> {
icon: FontAwesomeIcons.solidInputPipe, icon: FontAwesomeIcons.solidInputPipe,
title: 'Description', title: 'Description',
values: [_descriptionNameOverride ?? channelPreview?.descriptionName ?? ''], values: [_descriptionNameOverride ?? channelPreview?.descriptionName ?? ''],
iconActions: isOwned ? [(FontAwesomeIcons.penToSquare, _showEditDescriptionName)] : [], iconActions: isOwned ? [(FontAwesomeIcons.penToSquare, null, _showEditDescriptionName)] : [],
); );
} else if (_editDescriptionName == EditState.saving) { } else if (_editDescriptionName == EditState.saving) {
return Padding( return Padding(
@ -447,14 +505,6 @@ class _ChannelViewPageState extends State<ChannelViewPage> {
} }
} }
void _subscribe() {
//TODO
}
void _unsubscribe() {
//TODO
}
void _showEditDisplayName() { void _showEditDisplayName() {
setState(() { setState(() {
_ctrlDisplayName.text = _displayNameOverride ?? channelPreview?.displayName ?? ''; _ctrlDisplayName.text = _displayNameOverride ?? channelPreview?.displayName ?? '';
@ -519,25 +569,144 @@ class _ChannelViewPageState extends State<ChannelViewPage> {
} }
} }
void _cancelForeignSubscription(Subscription sub) { void _subscribe() async {
//TODO 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 _confirmForeignSubscription(Subscription sub) { void _unsubscribe({String? confirm = null}) async {
//TODO final acc = AppAuth();
if (subscription == null) return;
if (confirm != null) {
final r = await UIDialogs.showConfirmDialog(context, confirm, okText: 'Unsubscribe', cancelText: 'Cancel');
if (!r) 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 _denyForeignSubscription(Subscription sub) { void _deactivate() async {
//TODO final acc = AppAuth();
if (subscription == null) return;
try {
await APIClient.deactivateSubscription(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 _activate() async {
final acc = AppAuth();
if (subscription == null) return;
try {
await APIClient.activateSubscription(acc, widget.channelID, subscription!.subscriptionID);
widget.needsReload?.call();
await _initStateAsync(false);
Toaster.success("Success", 'Subscribed 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 _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) { String _formatSubscriptionStatus(Subscription? subscription) {
if (subscription == null) { if (subscription == null) {
return 'Not Subscribed'; return 'Not Subscribed';
} else if (subscription.confirmed) { } else if (subscription.confirmed && subscription.active) {
return 'Subscribed'; return 'Subscribed & Active';
} else { } else if (subscription.confirmed && !subscription.active) {
return 'Subscribed & Inactive';
} else if (!subscription.confirmed) {
return 'Requested'; return 'Requested';
} else {
return '?';
} }
} }
@ -591,13 +760,13 @@ class _ChannelViewPageState extends State<ChannelViewPage> {
} }
} }
List<(IconData, void Function())> _getForeignSubActions(Subscription sub) { List<(IconData, Color?, void Function())> _getForeignIncomingSubActions(Subscription sub) {
if (sub.confirmed) { if (sub.confirmed) {
return [(FontAwesomeIcons.solidSquareXmark, () => _cancelForeignSubscription(sub))]; return [(FontAwesomeIcons.solidSquareXmark, Colors.red[900], () => _cancelForeignSubscription(sub))];
} else { } else {
return [ return [
(FontAwesomeIcons.solidSquareCheck, () => _confirmForeignSubscription(sub)), (FontAwesomeIcons.solidSquareCheck, Colors.green[900], () => _confirmForeignSubscription(sub)),
(FontAwesomeIcons.solidSquareXmark, () => _denyForeignSubscription(sub)), (FontAwesomeIcons.solidSquareXmark, Colors.red[900], () => _denyForeignSubscription(sub)),
]; ];
} }
} }

View File

@ -0,0 +1,86 @@
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/client.dart';
import 'package:simplecloudnotifier/state/application_log.dart';
import 'package:simplecloudnotifier/state/app_auth.dart';
import 'package:simplecloudnotifier/pages/client_list/client_list_item.dart';
class ClientListPage extends StatefulWidget {
const ClientListPage({super.key});
@override
State<ClientListPage> createState() => _ClientListPageState();
}
class _ClientListPageState extends State<ClientListPage> {
final PagingController<int, Client> _pagingController = PagingController.fromValue(PagingState(nextPageKey: null, itemList: [], error: null), firstPageKey: 0);
@override
void initState() {
super.initState();
_pagingController.addPageRequestListener(_fetchPage);
_pagingController.refresh();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
}
@override
void dispose() {
ApplicationLog.debug('ClientListPage::dispose');
_pagingController.dispose();
super.dispose();
}
Future<void> _fetchPage(int pageKey) async {
final acc = Provider.of<AppAuth>(context, listen: false);
ApplicationLog.debug('Start ClientListPage::_pagingController::_fetchPage [ ${pageKey} ]');
if (!acc.isAuth()) {
_pagingController.error = 'Not logged in';
return;
}
try {
final items = (await APIClient.getClientList(acc)).toList();
items.sort((a, b) => -1 * a.timestampCreated.compareTo(b.timestampCreated));
_pagingController.value = PagingState(nextPageKey: null, itemList: items, error: null);
} catch (exc, trace) {
_pagingController.error = exc.toString();
ApplicationLog.error('Failed to list clients: ' + exc.toString(), trace: trace);
}
}
@override
Widget build(BuildContext context) {
return SCNScaffold(
title: "Clients",
showSearch: false,
showShare: false,
child: Padding(
padding: EdgeInsets.fromLTRB(8, 4, 8, 4),
child: RefreshIndicator(
onRefresh: () => Future.sync(
() => _pagingController.refresh(),
),
child: PagedListView<int, Client>(
pagingController: _pagingController,
builderDelegate: PagedChildBuilderDelegate<Client>(
itemBuilder: (context, item, index) => ClientListItem(item: item),
),
),
),
),
);
}
}

View File

@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:provider/provider.dart';
import 'package:simplecloudnotifier/models/client.dart';
import 'package:simplecloudnotifier/state/app_settings.dart';
enum ClientListItemMode {
Messages,
Extended,
}
class ClientListItem extends StatelessWidget {
const ClientListItem({
required this.item,
super.key,
});
final Client item;
@override
Widget build(BuildContext context) {
final dateFormat = context.select<AppSettings, AppSettingsDateFormat>((v) => v.dateFormat).dateOnlyFormat();
return Card.filled(
margin: EdgeInsets.fromLTRB(0, 4, 0, 4),
shape: BeveledRectangleBorder(borderRadius: BorderRadius.circular(0)),
color: Theme.of(context).cardTheme.color,
child: Padding(
padding: const EdgeInsets.all(8),
child: Row(
children: [
_buildIcon(context),
SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: Text(
item.name ?? item.clientID,
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
Text(
dateFormat.format(DateTime.parse(item.timestampCreated).toLocal()),
style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(160)),
),
],
),
SizedBox(height: 4),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Text(item.agentModel.toString() + " " + item.agentVersion.toString()),
),
],
),
],
),
),
],
),
),
);
}
Widget _buildIcon(BuildContext context) {
if (item.type == "ANDROID") return Icon(FontAwesomeIcons.android, color: Theme.of(context).colorScheme.outline, size: 32);
if (item.type == "IOS") return Icon(FontAwesomeIcons.apple, color: Theme.of(context).colorScheme.outline, size: 32);
if (item.type == "LINUX") return Icon(FontAwesomeIcons.linux, color: Theme.of(context).colorScheme.outline, size: 32);
if (item.type == "MACOS") return Icon(FontAwesomeIcons.appleWhole, color: Theme.of(context).colorScheme.outline, size: 32);
if (item.type == "WINDOWS") return Icon(FontAwesomeIcons.windows, color: Theme.of(context).colorScheme.outline, size: 32);
return Icon(FontAwesomeIcons.solidSignature, color: Theme.of(context).colorScheme.outline, size: 32);
}
}

View File

@ -1,4 +1,10 @@
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:simplecloudnotifier/api/api_client.dart';
import 'package:simplecloudnotifier/state/app_settings.dart';
import 'package:simplecloudnotifier/state/app_auth.dart';
import 'package:simplecloudnotifier/state/application_log.dart';
import 'package:simplecloudnotifier/utils/notifier.dart'; import 'package:simplecloudnotifier/utils/notifier.dart';
import 'package:simplecloudnotifier/utils/toaster.dart'; import 'package:simplecloudnotifier/utils/toaster.dart';
import 'package:simplecloudnotifier/utils/ui.dart'; import 'package:simplecloudnotifier/utils/ui.dart';
@ -48,6 +54,12 @@ class _DebugActionsPageState extends State<DebugActionsPage> {
text: 'Show Simple Notification', text: 'Show Simple Notification',
), ),
SizedBox(height: 20), SizedBox(height: 20),
UI.button(
big: false,
onPressed: _copyToken,
text: 'Query+Copy FCM Token',
),
SizedBox(height: 4),
UI.button( UI.button(
big: false, big: false,
onPressed: _sendTokenToServer, onPressed: _sendTokenToServer,
@ -56,8 +68,32 @@ class _DebugActionsPageState extends State<DebugActionsPage> {
SizedBox(height: 20), SizedBox(height: 20),
UI.button( UI.button(
big: false, big: false,
onPressed: () => Notifier.showLocalNotification('', 'TEST_CHANNEL', "Test Channel", "Channel for testing", "Hello World", "Local Notification test", null), onPressed: () => Notifier.showLocalNotification('', 'TEST_CHANNEL', "Test Channel", "Channel for testing", "Hello World", "Local Notification test", null, null),
text: 'Show local notification', text: 'Show local notification (generic)',
),
UI.button(
big: false,
onPressed: () => Notifier.showLocalNotification('', 'TEST_CHANNEL', "Test Channel", "Channel for testing", "Hello World", "Local Notification test", null, 0),
text: 'Show local notification (Prio = 0)',
),
UI.button(
big: false,
onPressed: () => Notifier.showLocalNotification('', 'TEST_CHANNEL', "Test Channel", "Channel for testing", "Hello World", "Local Notification test", null, 1),
text: 'Show local notification (Prio = 1)',
),
UI.button(
big: false,
onPressed: () => Notifier.showLocalNotification('', 'TEST_CHANNEL', "Test Channel", "Channel for testing", "Hello World", "Local Notification test", null, 2),
text: 'Show local notification (Prio = 2)',
),
SizedBox(height: 20),
UI.button(
big: false,
onPressed: () {
AppSettings().update((p) => p.reset());
Toaster.success("Success", "AppSettings reset to default");
},
text: 'Reset AppSettings to default',
), ),
], ],
), ),
@ -66,7 +102,46 @@ class _DebugActionsPageState extends State<DebugActionsPage> {
); );
} }
void _sendTokenToServer() { void _sendTokenToServer() async {
//TODO try {
final auth = AppAuth();
final clientID = auth.getClientID();
if (clientID == null) {
Toaster.error("Error", "No Client set");
return;
}
final fcmToken = await FirebaseMessaging.instance.getToken();
if (fcmToken == null) {
Toaster.error("Error", "No FCM token returned from Firebase");
return;
}
var newClient = await APIClient.updateClient(auth, clientID, fcmToken: fcmToken);
auth.setClientAndClientID(newClient);
Toaster.success("Success", "Token sent to server");
} catch (exc, trace) {
Toaster.error("Error", "An error occurred while sending the token: ${exc.toString()}");
ApplicationLog.error("An error occurred while sending the token: ${exc.toString()}", trace: trace);
}
}
void _copyToken() async {
try {
final fcmToken = await FirebaseMessaging.instance.getToken();
if (fcmToken == null) {
Toaster.error("Error", "No FCM token returned from Firebase");
return;
}
Clipboard.setData(new ClipboardData(text: fcmToken));
Toaster.info("Clipboard", 'Copied text to Clipboard');
print('================= [CLIPBOARD] =================\n${fcmToken}\n================= [/CLIPBOARD] =================');
} catch (exc, trace) {
Toaster.error("Error", "An error occurred while sending the token: ${exc.toString()}");
ApplicationLog.error("An error occurred while sending the token: ${exc.toString()}", trace: trace);
}
} }
} }

View File

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

View File

@ -11,7 +11,7 @@ class DebugLogsPage extends StatefulWidget {
class _DebugLogsPageState extends State<DebugLogsPage> { class _DebugLogsPageState extends State<DebugLogsPage> {
Box<SCNLog> logBox = Hive.box<SCNLog>('scn-logs'); Box<SCNLog> logBox = Hive.box<SCNLog>('scn-logs');
static final _dateFormat = DateFormat('yyyy-MM-dd kk:mm'); static final _dateFormat = DateFormat('yyyy-MM-dd HH:mm');
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {

View File

@ -23,7 +23,7 @@ class _DebugMainPageState extends State<DebugMainPage> {
DebugMainPageSubPage.actions: DebugActionsPage(), DebugMainPageSubPage.actions: DebugActionsPage(),
}; };
DebugMainPageSubPage _subPage = DebugMainPageSubPage.colors; DebugMainPageSubPage _subPage = DebugMainPageSubPage.logs;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -53,14 +53,14 @@ class _DebugMainPageState extends State<DebugMainPage> {
return SegmentedButton<DebugMainPageSubPage>( return SegmentedButton<DebugMainPageSubPage>(
showSelectedIcon: false, showSelectedIcon: false,
segments: const <ButtonSegment<DebugMainPageSubPage>>[ segments: const <ButtonSegment<DebugMainPageSubPage>>[
ButtonSegment<DebugMainPageSubPage>(value: DebugMainPageSubPage.colors, icon: Icon(FontAwesomeIcons.solidPaintRoller, size: 14)), ButtonSegment<DebugMainPageSubPage>(value: DebugMainPageSubPage.logs, icon: Icon(FontAwesomeIcons.solidFileLines, size: 14)),
ButtonSegment<DebugMainPageSubPage>(value: DebugMainPageSubPage.actions, icon: Icon(FontAwesomeIcons.solidHammer, size: 14)), ButtonSegment<DebugMainPageSubPage>(value: DebugMainPageSubPage.actions, icon: Icon(FontAwesomeIcons.solidHammer, size: 14)),
ButtonSegment<DebugMainPageSubPage>(value: DebugMainPageSubPage.requests, icon: Icon(FontAwesomeIcons.solidNetworkWired, size: 14)), ButtonSegment<DebugMainPageSubPage>(value: DebugMainPageSubPage.requests, icon: Icon(FontAwesomeIcons.solidNetworkWired, size: 14)),
ButtonSegment<DebugMainPageSubPage>(value: DebugMainPageSubPage.persistence, icon: Icon(FontAwesomeIcons.solidDatabase, size: 14)), ButtonSegment<DebugMainPageSubPage>(value: DebugMainPageSubPage.persistence, icon: Icon(FontAwesomeIcons.solidDatabase, size: 14)),
ButtonSegment<DebugMainPageSubPage>(value: DebugMainPageSubPage.logs, icon: Icon(FontAwesomeIcons.solidFileLines, size: 14)), ButtonSegment<DebugMainPageSubPage>(value: DebugMainPageSubPage.colors, icon: Icon(FontAwesomeIcons.solidPaintRoller, size: 14)),
], ],
style: ButtonStyle( 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), visualDensity: VisualDensity(horizontal: -3, vertical: -3),
), ),
selected: <DebugMainPageSubPage>{_subPage}, selected: <DebugMainPageSubPage>{_subPage},

View File

@ -1,15 +1,20 @@
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart'; import 'package:hive_flutter/hive_flutter.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:simplecloudnotifier/models/channel.dart'; import 'package:simplecloudnotifier/models/channel.dart';
import 'package:simplecloudnotifier/models/scn_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_hive.dart';
import 'package:simplecloudnotifier/pages/debug/debug_persistence_sharedprefs.dart'; import 'package:simplecloudnotifier/pages/debug/debug_persistence_sharedprefs.dart';
import 'package:simplecloudnotifier/state/application_log.dart'; import 'package:simplecloudnotifier/state/application_log.dart';
import 'package:simplecloudnotifier/state/fb_message.dart'; import 'package:simplecloudnotifier/state/fb_message.dart';
import 'package:simplecloudnotifier/state/globals.dart';
import 'package:simplecloudnotifier/state/interfaces.dart'; import 'package:simplecloudnotifier/state/interfaces.dart';
import 'package:simplecloudnotifier/state/request_log.dart'; import 'package:simplecloudnotifier/state/request_log.dart';
import 'package:simplecloudnotifier/utils/navi.dart'; import 'package:simplecloudnotifier/utils/navi.dart';
import 'package:path/path.dart' as path;
class DebugPersistencePage extends StatefulWidget { class DebugPersistencePage extends StatefulWidget {
@override @override
@ -39,6 +44,7 @@ class _DebugPersistencePageState extends State<DebugPersistencePage> {
_buildHiveCard(context, () => Hive.box<SCNMessage>('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<Channel>('scn-channel-cache'), 'scn-channel-cache'),
_buildHiveCard(context, () => Hive.box<FBMessage>('scn-fb-messages'), 'scn-fb-messages'), _buildHiveCard(context, () => Hive.box<FBMessage>('scn-fb-messages'), 'scn-fb-messages'),
_buildFailureLogCard(context, Globals().rawFailureLogsDir),
], ],
), ),
); );
@ -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,64 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:simplecloudnotifier/components/error_display/error_display.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 ErrorDisplay(errorMessage: '${snapshot.error}');
} 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

@ -6,7 +6,9 @@ class DebugSharedPrefPage extends StatelessWidget {
final SharedPreferences sharedPref; final SharedPreferences sharedPref;
final List<String> keys; final List<String> keys;
DebugSharedPrefPage({required this.sharedPref}) : keys = sharedPref.getKeys().toList(); DebugSharedPrefPage({required this.sharedPref}) : keys = sharedPref.getKeys().toList() {
keys.sort((a, b) => a.compareTo(b));
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {

View File

@ -1,3 +1,5 @@
import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
@ -6,11 +8,19 @@ import 'package:simplecloudnotifier/state/request_log.dart';
import 'package:simplecloudnotifier/utils/toaster.dart'; import 'package:simplecloudnotifier/utils/toaster.dart';
import 'package:simplecloudnotifier/utils/ui.dart'; import 'package:simplecloudnotifier/utils/ui.dart';
class DebugRequestViewPage extends StatelessWidget { class DebugRequestViewPage extends StatefulWidget {
final SCNRequest request; final SCNRequest request;
DebugRequestViewPage({required this.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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SCNScaffold( return SCNScaffold(
@ -23,22 +33,23 @@ class DebugRequestViewPage extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: MainAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start,
children: [ children: [
...buildRow(context, "Name", request.name), ...buildRow(context, "name", "Name", widget.request.name),
...buildRow(context, "Timestamp (Start)", request.timestampStart.toString()), ...buildRow(context, "timestampStart", "Timestamp (Start)", widget.request.timestampStart.toString()),
...buildRow(context, "Timestamp (End)", request.timestampEnd.toString()), ...buildRow(context, "timestampEnd", "Timestamp (End)", widget.request.timestampEnd.toString()),
...buildRow(context, "Duration", request.timestampEnd.difference(request.timestampStart).toString()), ...buildRow(context, "duration", "Duration", widget.request.timestampEnd.difference(widget.request.timestampStart).toString()),
Divider(), Divider(),
...buildRow(context, "Method", request.method), ...buildRow(context, "method", "Method", widget.request.method),
...buildRow(context, "URL", request.url), ...buildRow(context, "url", "URL", widget.request.url, mono: true),
if (request.requestHeaders.isNotEmpty) ...buildRow(context, "Request->Headers", request.requestHeaders.entries.map((v) => '${v.key} = ${v.value}').join('\n')), 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 (request.requestBody != '') ...buildRow(context, "Request->Body", request.requestBody), if (widget.request.requestBody != '') ...buildRow(context, "request_body", "Request->Body", widget.request.requestBody, mono: true, json: true),
UI.button(text: "Copy request as curl", onPressed: _copyCurl, tonal: true),
Divider(), Divider(),
if (request.responseStatusCode != 0) ...buildRow(context, "Response->Statuscode", request.responseStatusCode.toString()), if (widget.request.responseStatusCode != 0) ...buildRow(context, "response_statuscode", "Response->Statuscode", widget.request.responseStatusCode.toString()),
if (request.responseBody != '') ...buildRow(context, "Reponse->Body", request.responseBody), if (widget.request.responseBody != '') ...buildRow(context, "response_body", "Reponse->Body", widget.request.responseBody, mono: true, json: true),
if (request.responseHeaders.isNotEmpty) ...buildRow(context, "Reponse->Headers", request.responseHeaders.entries.map((v) => '${v.key} = ${v.value}').join('\n')), 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: false),
Divider(), Divider(),
if (request.error != '') ...buildRow(context, "Error", request.error), if (widget.request.error != '') ...buildRow(context, "error", "Error", widget.request.error, mono: true),
if (request.stackTrace != '') ...buildRow(context, "Stacktrace", request.stackTrace), if (widget.request.stackTrace != '') ...buildRow(context, "trace", "Stacktrace", widget.request.stackTrace, mono: true),
], ],
), ),
), ),
@ -46,7 +57,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 [ return [
Padding( Padding(
padding: const EdgeInsets.symmetric(vertical: 0, horizontal: 8.0), padding: const EdgeInsets.symmetric(vertical: 0, horizontal: 8.0),
@ -64,21 +87,62 @@ class DebugRequestViewPage extends StatelessWidget {
}, },
icon: FontAwesomeIcons.copy, 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( Card.filled(
shape: BeveledRectangleBorder(borderRadius: BorderRadius.circular(0)), 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( child: Padding(
padding: const EdgeInsets.symmetric(vertical: 2.0, horizontal: 6.0), padding: const EdgeInsets.symmetric(vertical: 2.0, horizontal: 6.0),
child: SelectableText( child: (isMono || isJson)
value, ? SingleChildScrollView(
minLines: 1, scrollDirection: Axis.horizontal,
maxLines: 10, child: SelectableText(
), value,
minLines: 1,
maxLines: 10,
),
)
: SelectableText(
value,
minLines: 1,
maxLines: 10,
),
), ),
), ),
]; ];
} }
void _copyCurl() {
final method = '-X ${widget.request.method}';
final header = widget.request.requestHeaders.entries.map((v) => '-H "${v.key}: ${v.value}"').join(' ');
final body = widget.request.requestBody.isNotEmpty ? '-d "${widget.request.requestBody}"' : '';
final curlParts = ['curl', method, header, '"${widget.request.url}"', body];
final txt = curlParts.where((part) => part.isNotEmpty).join(' ');
Clipboard.setData(new ClipboardData(text: txt));
Toaster.info("Clipboard", 'Copied text to Clipboard');
print('================= [CLIPBOARD] =================\n${txt}\n================= [/CLIPBOARD] =================');
}
} }

View File

@ -13,7 +13,7 @@ class DebugRequestsPage extends StatefulWidget {
class _DebugRequestsPageState extends State<DebugRequestsPage> { class _DebugRequestsPageState extends State<DebugRequestsPage> {
Box<SCNRequest> requestsBox = Hive.box<SCNRequest>('scn-requests'); Box<SCNRequest> requestsBox = Hive.box<SCNRequest>('scn-requests');
static final _dateFormat = DateFormat('yyyy-MM-dd kk:mm'); static final _dateFormat = DateFormat('yyyy-MM-dd HH:mm');
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -47,10 +47,6 @@ class _DebugRequestsPageState extends State<DebugRequestsPage> {
textColor: Theme.of(context).colorScheme.onErrorContainer, textColor: Theme.of(context).colorScheme.onErrorContainer,
title: Row( title: Row(
children: [ children: [
SizedBox(
width: 120,
child: Text(_dateFormat.format(req.timestampStart), style: TextStyle(fontSize: 12)),
),
Expanded( Expanded(
child: Text(req.name, style: TextStyle(fontWeight: FontWeight.bold)), child: Text(req.name, style: TextStyle(fontWeight: FontWeight.bold)),
), ),
@ -61,7 +57,14 @@ class _DebugRequestsPageState extends State<DebugRequestsPage> {
subtitle: Column( subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(req.type), Row(
children: [
Text(_dateFormat.format(req.timestampStart), style: TextStyle(fontSize: 12)),
Expanded(child: SizedBox()),
Text(req.type),
],
),
SizedBox(height: 16),
Text( Text(
req.error, req.error,
maxLines: 1, maxLines: 1,
@ -81,10 +84,6 @@ class _DebugRequestsPageState extends State<DebugRequestsPage> {
child: ListTile( child: ListTile(
title: Row( title: Row(
children: [ children: [
SizedBox(
width: 120,
child: Text(_dateFormat.format(req.timestampStart), style: TextStyle(fontSize: 12)),
),
Expanded( Expanded(
child: Text(req.name, style: TextStyle(fontWeight: FontWeight.bold)), child: Text(req.name, style: TextStyle(fontWeight: FontWeight.bold)),
), ),
@ -92,7 +91,13 @@ class _DebugRequestsPageState extends State<DebugRequestsPage> {
Text('${req.timestampEnd.difference(req.timestampStart).inMilliseconds}ms', style: TextStyle(fontSize: 12)), Text('${req.timestampEnd.difference(req.timestampStart).inMilliseconds}ms', style: TextStyle(fontSize: 12)),
], ],
), ),
subtitle: Text(req.type), subtitle: Row(
children: [
Text(_dateFormat.format(req.timestampStart), style: TextStyle(fontSize: 12)),
Expanded(child: SizedBox()),
Text(req.type),
],
),
), ),
), ),
); );

View File

@ -0,0 +1,120 @@
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/state/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 FilteredMessageViewPage extends StatefulWidget {
const FilteredMessageViewPage({
required this.title,
required this.filter,
super.key,
});
final String title;
final MessageFilter filter;
@override
State<FilteredMessageViewPage> createState() => _FilteredMessageViewPageState();
}
class _FilteredMessageViewPageState extends State<FilteredMessageViewPage> {
PagingController<String, SCNMessage> _pagingController = PagingController.fromValue(PagingState(nextPageKey: null, itemList: [], error: null), firstPageKey: '@start');
Map<String, Channel>? _channels = null;
bool _channelsFetched = false;
@override
void initState() {
super.initState();
_channels = SCNDataCache().getChannelMap();
_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 FilteredMessageViewPage::_pagingController::_fetchPage [ ${thisPageToken} ]');
if (!acc.isAuth()) {
_pagingController.error = 'Not logged in';
return;
}
try {
if (_channels == null || !_channelsFetched) {
final channels = await APIClient.getChannelList(acc, ChannelSelector.allAny);
setState(() {
_channels = <String, Channel>{for (var v in channels) v.channel.channelID: v.channel};
_channelsFetched = true;
});
}
final (npt, newItems) = await APIClient.getMessageList(acc, thisPageToken, pageSize: cfg.messagePageSize, filter: this.widget.filter, includeNonSuscribed: true);
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.title,
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: _channels ?? {},
onPressed: () {
Navi.push(context, () => MessageViewPage(messageID: item.messageID, preloadedData: (item,)));
},
),
),
),
),
);
}
}

View File

@ -0,0 +1,216 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:simplecloudnotifier/api/api_client.dart';
import 'package:simplecloudnotifier/components/error_display/error_display.dart';
import 'package:simplecloudnotifier/models/channel.dart';
import 'package:simplecloudnotifier/models/keytoken.dart';
import 'package:simplecloudnotifier/state/app_auth.dart';
import 'package:simplecloudnotifier/state/application_log.dart';
import 'package:simplecloudnotifier/types/immediate_future.dart';
import 'package:simplecloudnotifier/utils/toaster.dart';
class KeyTokenCreateDialog extends StatefulWidget {
final void Function(KeyToken, String) onCreated;
const KeyTokenCreateDialog({
required this.onCreated,
Key? key,
}) : super(key: key);
@override
_KeyTokenCreateDialogState createState() => _KeyTokenCreateDialogState();
}
class _KeyTokenCreateDialogState extends State<KeyTokenCreateDialog> {
TextEditingController _ctrlName = TextEditingController();
Set<String> selectedPermissions = {'CS'};
ImmediateFuture<List<Channel>> _futureOwnedChannels = ImmediateFuture.ofPending();
bool allChannels = true;
Set<String> selectedChannels = new Set<String>();
@override
void initState() {
super.initState();
final userAcc = Provider.of<AppAuth>(context, listen: false);
setState(() {
_futureOwnedChannels = ImmediateFuture.ofFuture(APIClient.getChannelList(userAcc, ChannelSelector.owned).then((p) => p.map((c) => c.channel).toList()));
});
}
@override
void dispose() {
_ctrlName.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Create new key'),
content: Container(
width: 0,
height: 400,
child: SingleChildScrollView(
child: Column(
children: [
_buildNameCtrl(context),
SizedBox(height: 32),
_buildPermissionCtrl(context),
SizedBox(height: 32),
_buildChannelCtrl(context),
],
),
),
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text('Cancel'),
),
TextButton(
style: TextButton.styleFrom(textStyle: Theme.of(context).textTheme.labelLarge),
child: const Text('Create'),
onPressed: _create,
),
],
);
}
Widget _buildNameCtrl(BuildContext context) {
return TextField(
controller: _ctrlName,
decoration: const InputDecoration(
labelText: 'Key name',
hintText: 'Enter a name for the new key',
),
);
}
Widget _buildPermissionCtrl(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Permissions:', style: Theme.of(context).textTheme.labelLarge, textAlign: TextAlign.start),
ListView.builder(
shrinkWrap: true,
primary: false,
itemBuilder: (builder, index) {
final txt = (['Admin', 'Read messages', 'Send messages', 'Read userdata'])[index];
final prm = (['A', 'CR', 'CS', 'UR'])[index];
return ListTile(
contentPadding: EdgeInsets.fromLTRB(8, 0, 8, 0),
visualDensity: VisualDensity(horizontal: 0, vertical: -4),
title: Text(txt),
leading: Icon(
selectedPermissions.contains(prm) ? Icons.check_box : Icons.check_box_outline_blank,
color: Theme.of(context).primaryColor,
),
onTap: () {
setState(() {
if (selectedPermissions.contains(prm)) {
selectedPermissions.remove(prm);
} else {
selectedPermissions.add(prm);
}
});
},
);
},
itemCount: 4,
)
],
);
}
Widget _buildChannelCtrl(BuildContext context) {
return FutureBuilder<List<Channel>>(
future: _futureOwnedChannels.future,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return Center(child: CircularProgressIndicator());
} else if (snapshot.hasError) {
return ErrorDisplay(errorMessage: '${snapshot.error}');
}
final ownChannels = snapshot.data!;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Channels:', style: Theme.of(context).textTheme.labelLarge, textAlign: TextAlign.start),
ListTile(
contentPadding: EdgeInsets.fromLTRB(8, 0, 8, 0),
visualDensity: VisualDensity(horizontal: 0, vertical: -4),
title: Text('All Channels'),
leading: Icon(
allChannels ? Icons.check_box : Icons.check_box_outline_blank,
color: Theme.of(context).primaryColor,
),
onTap: () {
setState(() {
allChannels = !allChannels;
});
},
),
SizedBox(height: 16),
if (!allChannels)
ListView.builder(
shrinkWrap: true,
primary: false,
itemBuilder: (builder, index) {
return ListTile(
contentPadding: EdgeInsets.fromLTRB(8, 0, 8, 0),
visualDensity: VisualDensity(horizontal: 0, vertical: -4),
title: Text(ownChannels[index].displayName),
leading: Icon(
selectedChannels.contains(ownChannels[index].channelID) ? Icons.check_box : Icons.check_box_outline_blank,
color: Theme.of(context).primaryColor,
),
onTap: () {
setState(() {
if (selectedChannels.contains(ownChannels[index].channelID)) {
selectedChannels.remove(ownChannels[index].channelID);
} else {
selectedChannels.add(ownChannels[index].channelID);
}
});
},
);
},
itemCount: ownChannels.length,
),
],
);
},
);
}
void _create() async {
final userAcc = Provider.of<AppAuth>(context, listen: false);
if (!userAcc.isAuth()) return;
if (_ctrlName.text.isEmpty) {
Toaster.error('Missing data', 'Please enter a name for the key');
return;
}
try {
final perm = selectedPermissions.join(';');
final channels = allChannels ? <String>[] : selectedChannels.toList();
var kt = await APIClient.createKeyToken(userAcc, _ctrlName.text, perm, allChannels, channels: channels);
Toaster.success('Success', 'Key created successfully');
Navigator.of(context).pop();
widget.onCreated(kt.keyToken, kt.token);
} catch (exc, trace) {
ApplicationLog.error('Failed to create keytoken: ' + exc.toString(), trace: trace);
Toaster.error("Error", 'Failed to create key: ${exc.toString()}');
}
}
}

View File

@ -0,0 +1,97 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:simplecloudnotifier/components/badge_display/badge_display.dart';
import 'package:simplecloudnotifier/models/keytoken.dart';
import 'package:simplecloudnotifier/utils/toaster.dart';
import 'package:simplecloudnotifier/utils/ui.dart';
class KeyTokenCreatedModal extends StatelessWidget {
final KeyToken keytoken;
final String tokenValue;
const KeyTokenCreatedModal({
Key? key,
required this.keytoken,
required this.tokenValue,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('A new key was created'),
content: Container(
width: 0,
height: 350,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidIdCardClip,
title: 'KeyTokenID',
values: [keytoken.keytokenID],
),
UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidInputText,
title: 'Name',
values: [keytoken.name],
),
UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidShieldKeyhole,
title: 'Permissions',
values: _formatPermissions(keytoken.permissions),
),
const SizedBox(height: 16),
const BadgeDisplay(
text: "Please copy and save the token now, it cannot be retrieved later.",
icon: null,
mode: BadgeMode.warn,
),
const SizedBox(height: 4),
UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidKey,
title: 'Token',
values: [tokenValue.substring(0, 12) + '...'],
iconActions: [(FontAwesomeIcons.copy, null, _copy)],
),
],
),
),
),
actions: <Widget>[
TextButton(
child: const Text('Close'),
onPressed: () {
Navigator.of(context).pop();
},
),
],
);
}
List<String> _formatPermissions(String v) {
var splt = v.split(';');
if (splt.length == 0) return ["None"];
List<String> result = [];
if (splt.contains("A")) result.add("Admin");
if (splt.contains("UR")) result.add("Read Account");
if (splt.contains("CR")) result.add("Read Messages");
if (splt.contains("CS")) result.add("Send Messages");
return result;
}
void _copy() {
Clipboard.setData(new ClipboardData(text: tokenValue));
Toaster.info("Clipboard", 'Copied text to Clipboard');
print('================= [CLIPBOARD] =================\n${tokenValue}\n================= [/CLIPBOARD] =================');
}
}

View File

@ -0,0 +1,115 @@
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/components/layout/scaffold.dart';
import 'package:simplecloudnotifier/models/keytoken.dart';
import 'package:simplecloudnotifier/pages/keytoken_list/keytoken_create_modal.dart';
import 'package:simplecloudnotifier/pages/keytoken_list/keytoken_created_modal.dart';
import 'package:simplecloudnotifier/state/application_log.dart';
import 'package:simplecloudnotifier/state/app_auth.dart';
import 'package:simplecloudnotifier/pages/keytoken_list/keytoken_list_item.dart';
class KeyTokenListPage extends StatefulWidget {
const KeyTokenListPage({super.key});
@override
State<KeyTokenListPage> createState() => _KeyTokenListPageState();
}
class _KeyTokenListPageState extends State<KeyTokenListPage> {
final PagingController<int, KeyToken> _pagingController = PagingController.fromValue(PagingState(nextPageKey: null, itemList: [], error: null), firstPageKey: 0);
@override
void initState() {
super.initState();
_pagingController.addPageRequestListener(_fetchPage);
_pagingController.refresh();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
}
@override
void dispose() {
ApplicationLog.debug('KeyTokenListPage::dispose');
_pagingController.dispose();
super.dispose();
}
Future<void> _fetchPage(int pageKey) async {
final acc = Provider.of<AppAuth>(context, listen: false);
ApplicationLog.debug('Start KeyTokenListPage::_pagingController::_fetchPage [ ${pageKey} ]');
if (!acc.isAuth()) {
_pagingController.error = 'Not logged in';
return;
}
try {
final items = (await APIClient.getKeyTokenList(acc)).toList();
items.sort((a, b) => -1 * a.timestampCreated.compareTo(b.timestampCreated));
_pagingController.value = PagingState(nextPageKey: null, itemList: items, error: null);
} catch (exc, trace) {
_pagingController.error = exc.toString();
ApplicationLog.error('Failed to list keytokens: ' + exc.toString(), trace: trace);
}
}
@override
Widget build(BuildContext context) {
return SCNScaffold(
title: "Keys",
showSearch: false,
showShare: false,
child: Padding(
padding: EdgeInsets.fromLTRB(8, 4, 8, 4),
child: RefreshIndicator(
onRefresh: () => Future.sync(
() => _pagingController.refresh(),
),
child: PagedListView<int, KeyToken>(
pagingController: _pagingController,
builderDelegate: PagedChildBuilderDelegate<KeyToken>(
itemBuilder: (context, item, index) => KeyTokenListItem(item: item, needsReload: _fullRefresh),
),
),
),
),
floatingActionButton: FloatingActionButton(
heroTag: 'fab_keytokenlist_plus',
onPressed: () {
showDialog<void>(
context: context,
builder: (context) => KeyTokenCreateDialog(onCreated: _created),
);
},
child: const Icon(FontAwesomeIcons.plus),
),
);
}
void _created(KeyToken token, String tokValue) {
setState(() {
_pagingController.itemList?.insert(0, token);
});
showDialog<void>(
context: context,
builder: (context) => KeyTokenCreatedModal(keytoken: token, tokenValue: tokValue),
);
}
void _fullRefresh() {
ApplicationLog.debug('KeytokenListPage::fullRefresh');
_pagingController.refresh();
}
}

View File

@ -0,0 +1,117 @@
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/keytoken.dart';
import 'package:simplecloudnotifier/pages/filtered_message_view/filtered_message_view.dart';
import 'package:simplecloudnotifier/pages/keytoken_view/keytoken_view.dart';
import 'package:simplecloudnotifier/state/app_settings.dart';
import 'package:simplecloudnotifier/utils/navi.dart';
enum KeyTokenListItemMode {
Messages,
Extended,
}
class KeyTokenListItem extends StatelessWidget {
const KeyTokenListItem({
required this.item,
required this.needsReload,
super.key,
});
final KeyToken item;
final void Function()? needsReload;
@override
Widget build(BuildContext context) {
final dateFormat = context.select<AppSettings, AppSettingsDateFormat>((v) => v.dateFormat).dateOnlyFormat();
return Card.filled(
margin: EdgeInsets.fromLTRB(0, 4, 0, 4),
shape: BeveledRectangleBorder(borderRadius: BorderRadius.circular(0)),
color: Theme.of(context).cardTheme.color,
child: InkWell(
onTap: () {
Navi.push(context, () => KeyTokenViewPage(keytokenID: item.keytokenID, preloadedData: item, needsReload: needsReload));
},
child: Padding(
padding: const EdgeInsets.all(8),
child: Row(
children: [
Icon(FontAwesomeIcons.solidGearCode, color: Theme.of(context).colorScheme.outline, size: 32),
SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: Text(
item.name,
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
Text(
(item.timestampLastUsed == null) ? '' : dateFormat.format(DateTime.parse(item.timestampLastUsed!).toLocal()),
style: const TextStyle(fontSize: 14),
),
],
),
SizedBox(height: 4),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Text(
"Permissions: " + _formatPermissions(item.permissions, item.allChannels, item.channels),
style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(160)),
),
),
Text(item.messagesSent.toString(), style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
],
),
],
),
),
SizedBox(width: 4),
GestureDetector(
onTap: () {
Navi.push(context, () => FilteredMessageViewPage(title: item.name, filter: MessageFilter(usedKeys: [item.keytokenID])));
},
child: Padding(
padding: const EdgeInsets.all(8),
child: Icon(FontAwesomeIcons.solidEnvelopes, color: Theme.of(context).colorScheme.onPrimaryContainer.withAlpha(128), size: 24),
),
),
],
),
),
),
);
}
String _formatPermissions(String v, bool allChannels, List<String> channels) {
var splt = v.split(';');
if (splt.length == 0) return "None";
var a = splt.contains("A");
var ur = splt.contains("UR");
var cr = splt.contains("CR");
var cs = splt.contains("CS");
if (a) return "Admin";
if (cr && cs && allChannels) return "Read+Send";
if (cr && cs && !allChannels) return "Read+Send (${channels.length} channel${channels.length == 1 ? '' : 's'})";
if (ur && !cr && !cs) return "Account-Read";
if (cr && !cs && !allChannels) return "Read-only (${channels.length} channel${channels.length == 1 ? '' : 's'})";
if (cr && !cs && allChannels) return "Read-only";
if (cs && !allChannels) return "Send-Only (${channels.length} channel${channels.length == 1 ? '' : 's'})";
if (cs && allChannels) return "Send-Only";
return "{ " + v + " | " + (allChannels ? 'all' : '${channels.length}') + " }";
}
}

View File

@ -0,0 +1,112 @@
import 'package:flutter/material.dart';
import 'package:simplecloudnotifier/models/channel.dart';
import 'package:simplecloudnotifier/models/keytoken.dart';
class EditKeyTokenChannelsDialog extends StatefulWidget {
final List<Channel> ownedChannels;
final KeyTokenPreview keytoken;
final void Function(Set<String>) onUpdateChannels;
final void Function() onUpdateSetAllChannels;
const EditKeyTokenChannelsDialog({
required this.ownedChannels,
required this.keytoken,
required this.onUpdateChannels,
required this.onUpdateSetAllChannels,
Key? key,
}) : super(key: key);
@override
_EditKeyTokenChannelsDialogState createState() => _EditKeyTokenChannelsDialogState();
}
class _EditKeyTokenChannelsDialogState extends State<EditKeyTokenChannelsDialog> {
late bool allChannels;
late Set<String> selectedEntries;
@override
void initState() {
super.initState();
allChannels = widget.keytoken.allChannels;
selectedEntries = (widget.keytoken.channels).toSet();
}
@override
Widget build(BuildContext context) {
var ownChannels = widget.ownedChannels.toList();
ownChannels.sort((a, b) => a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()));
return AlertDialog(
title: const Text('Channels'),
content: Container(
width: 0,
height: 400,
child: Column(
children: [
ListTile(
contentPadding: EdgeInsets.fromLTRB(8, 0, 8, 0),
visualDensity: VisualDensity(horizontal: 0, vertical: -4),
title: Text('All Channels'),
leading: Icon(
allChannels ? Icons.check_box : Icons.check_box_outline_blank,
color: Theme.of(context).primaryColor,
),
onTap: () {
setState(() {
allChannels = !allChannels;
});
},
),
SizedBox(height: 16),
if (!allChannels)
Expanded(
child: ListView.builder(
shrinkWrap: true,
itemBuilder: (builder, index) {
return ListTile(
contentPadding: EdgeInsets.fromLTRB(8, 0, 8, 0),
visualDensity: VisualDensity(horizontal: 0, vertical: -4),
title: Text(ownChannels[index].displayName),
leading: Icon(
selectedEntries.contains(ownChannels[index].channelID) ? Icons.check_box : Icons.check_box_outline_blank,
color: Theme.of(context).primaryColor,
),
onTap: () {
setState(() {
if (selectedEntries.contains(ownChannels[index].channelID)) {
selectedEntries.remove(ownChannels[index].channelID);
} else {
selectedEntries.add(ownChannels[index].channelID);
}
});
},
);
},
itemCount: ownChannels.length,
),
),
],
),
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text('Cancel'),
),
TextButton(
style: TextButton.styleFrom(textStyle: Theme.of(context).textTheme.labelLarge),
child: const Text('Update'),
onPressed: () {
if (allChannels) {
widget.onUpdateSetAllChannels();
} else {
widget.onUpdateChannels(selectedEntries);
}
Navigator.of(context).pop();
},
),
],
);
}
}

View File

@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
import 'package:simplecloudnotifier/models/keytoken.dart';
class EditKeyTokenPermissionsDialog extends StatefulWidget {
final KeyTokenPreview keytoken;
final void Function(String) onUpdatePermissions;
const EditKeyTokenPermissionsDialog({
required this.keytoken,
required this.onUpdatePermissions,
Key? key,
}) : super(key: key);
@override
_EditKeyTokenPermissionsDialogState createState() => _EditKeyTokenPermissionsDialogState();
}
class _EditKeyTokenPermissionsDialogState extends State<EditKeyTokenPermissionsDialog> {
Set<String> selectedPermissions = new Set<String>();
@override
void initState() {
super.initState();
for (var p in widget.keytoken.permissions.split(';')) {
if (p.isNotEmpty) selectedPermissions.add(p);
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Permissions'),
content: Container(
width: 0,
height: 400,
child: ListView.builder(
shrinkWrap: true,
itemBuilder: (builder, index) {
final txt = (['Admin', 'Read messages', 'Send messages', 'Read userdata'])[index];
final prm = (['A', 'CR', 'CS', 'UR'])[index];
return ListTile(
contentPadding: EdgeInsets.fromLTRB(8, 0, 8, 0),
visualDensity: VisualDensity(horizontal: 0, vertical: -4),
title: Text(txt),
leading: Icon(
selectedPermissions.contains(prm) ? Icons.check_box : Icons.check_box_outline_blank,
color: Theme.of(context).primaryColor,
),
onTap: () {
setState(() {
if (selectedPermissions.contains(prm)) {
selectedPermissions.remove(prm);
} else {
selectedPermissions.add(prm);
}
});
},
);
},
itemCount: 4,
),
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text('Cancel'),
),
TextButton(
style: TextButton.styleFrom(textStyle: Theme.of(context).textTheme.labelLarge),
child: const Text('Update'),
onPressed: () {
widget.onUpdatePermissions(selectedPermissions.join(';'));
Navigator.of(context).pop();
},
),
],
);
}
}

View File

@ -0,0 +1,612 @@
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:intl/intl.dart';
import 'package:simplecloudnotifier/api/api_client.dart';
import 'package:simplecloudnotifier/components/error_display/error_display.dart';
import 'package:simplecloudnotifier/components/layout/scaffold.dart';
import 'package:simplecloudnotifier/models/channel.dart';
import 'package:simplecloudnotifier/models/keytoken.dart';
import 'package:simplecloudnotifier/models/user.dart';
import 'package:simplecloudnotifier/pages/filtered_message_view/filtered_message_view.dart';
import 'package:simplecloudnotifier/pages/keytoken_view/keytoken_channel_modal.dart';
import 'package:simplecloudnotifier/pages/keytoken_view/keytoken_permission_modal.dart';
import 'package:simplecloudnotifier/state/app_auth.dart';
import 'package:simplecloudnotifier/state/app_bar_state.dart';
import 'package:simplecloudnotifier/state/app_settings.dart';
import 'package:simplecloudnotifier/state/application_log.dart';
import 'package:simplecloudnotifier/state/scn_data_cache.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';
import 'package:provider/provider.dart';
class KeyTokenViewPage extends StatefulWidget {
const KeyTokenViewPage({
required this.keytokenID,
required this.preloadedData,
required this.needsReload,
super.key,
});
final String keytokenID;
final KeyToken? preloadedData;
final void Function()? needsReload;
@override
State<KeyTokenViewPage> createState() => _KeyTokenViewPageState();
}
enum EditState { none, editing, saving }
enum KeyTokenViewPageInitState { loading, okay, error }
class _KeyTokenViewPageState extends State<KeyTokenViewPage> {
ImmediateFuture<UserPreview> _futureOwner = ImmediateFuture.ofPending();
ImmediateFuture<Map<String, ChannelPreview>> _futureAllChannels = ImmediateFuture.ofPending();
ImmediateFuture<List<Channel>> _futureOwnedChannels = ImmediateFuture.ofPending();
final TextEditingController _ctrlName = TextEditingController();
int _loadingIndeterminateCounter = 0;
EditState _editName = EditState.none;
String? _nameOverride = null;
KeyTokenPreview? keytokenPreview;
KeyToken? keytoken;
KeyTokenViewPageInitState loadingState = KeyTokenViewPageInitState.loading;
String errorMessage = '';
KeyToken? keytokenUserAccAdmin;
KeyToken? keytokenUserAccSend;
@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) {
keytoken = widget.preloadedData!;
keytokenPreview = widget.preloadedData!.toPreview();
} else {
try {
var p = await APIClient.getKeyTokenPreviewByID(userAcc, widget.keytokenID);
setState(() {
keytokenPreview = p;
});
if (p.ownerUserID == userAcc.userID) {
var r = await APIClient.getKeyToken(userAcc, widget.keytokenID);
setState(() {
keytoken = r;
});
} else {
setState(() {
keytoken = null;
});
}
} 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 = KeyTokenViewPageInitState.error;
return;
}
}
setState(() {
this.loadingState = KeyTokenViewPageInitState.okay;
assert(keytokenPreview != null);
if (this.keytokenPreview!.ownerUserID == userAcc.userID) {
var cacheUser = userAcc.getUserOrNull();
if (cacheUser != null) {
_futureOwner = ImmediateFuture.ofValue(cacheUser.toPreview());
} else {
_futureOwner = ImmediateFuture.ofFuture(_getOwner(userAcc));
}
} else {
_futureOwner = ImmediateFuture.ofFuture(APIClient.getUserPreview(userAcc, this.keytokenPreview!.ownerUserID));
}
});
setState(() {
_futureAllChannels = ImmediateFuture.ofFuture(APIClient.getChannelList(userAcc, ChannelSelector.allAny).then((lst) async {
Map<String, ChannelPreview> result = {};
for (var c in lst) result[c.channel.channelID] = c.channel.toPreview(c.subscription);
if (keytokenPreview != null) {
for (var cid in keytokenPreview!.channels) {
if (!result.containsKey(cid)) {
result[cid] = await APIClient.getChannelPreview(userAcc, cid);
}
}
}
return result;
}));
});
setState(() {
_futureOwnedChannels = ImmediateFuture.ofFuture(APIClient.getChannelList(userAcc, ChannelSelector.owned).then((p) => p.map((c) => c.channel).toList()));
});
SCNDataCache().getOrQueryTokenByValue(userAcc.userID!, userAcc.tokenAdmin!).then((token) {
setState(() {
keytokenUserAccAdmin = token;
});
});
SCNDataCache().getOrQueryTokenByValue(userAcc.userID!, userAcc.tokenSend!).then((token) {
setState(() {
keytokenUserAccSend = token;
});
});
}
@override
void dispose() {
_ctrlName.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final userAcc = Provider.of<AppAuth>(context, listen: false);
var title = "Key";
Widget child;
if (loadingState == KeyTokenViewPageInitState.loading) {
child = Center(child: CircularProgressIndicator());
} else if (loadingState == KeyTokenViewPageInitState.error) {
child = ErrorDisplay(errorMessage: errorMessage);
} else if (loadingState == KeyTokenViewPageInitState.okay && keytokenPreview!.ownerUserID == userAcc.userID) {
child = _buildOwnedKeyTokenView(context, this.keytoken!);
title = this.keytoken!.name;
} else {
child = _buildForeignKeyTokenView(context, this.keytokenPreview!);
title = keytokenPreview!.name;
}
return SCNScaffold(
title: title,
showSearch: false,
showShare: false,
child: child,
);
}
Widget _buildOwnedKeyTokenView(BuildContext context, KeyToken keytoken) {
final dateFormat = context.select<AppSettings, AppSettingsDateFormat>((v) => v.dateFormat).dateFormat();
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: 'KeyTokenID',
values: [
keytoken.keytokenID,
if (keytokenUserAccAdmin?.keytokenID == keytoken.keytokenID) '(Currently used as Admin-Token)',
if (keytokenUserAccSend?.keytokenID == keytoken.keytokenID) '(Currently used as Send-Token)',
],
),
_buildNameCard(context, true),
UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidClock,
title: 'Created',
values: [dateFormat.format(DateTime.parse(keytoken.timestampCreated).toLocal())],
),
UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidClockTwo,
title: 'Last Used',
values: [(keytoken.timestampLastUsed == null) ? 'Never' : dateFormat.format(DateTime.parse(keytoken.timestampLastUsed!).toLocal())],
),
_buildOwnerCard(context, true),
UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidEnvelope,
title: 'Messages',
values: [keytoken.messagesSent.toString()],
mainAction: () {
Navi.push(context, () => FilteredMessageViewPage(title: keytoken.name, filter: MessageFilter(usedKeys: [keytoken.keytokenID])));
},
),
..._buildPermissionCard(context, true, keytoken.toPreview()),
UI.button(text: "Delete Key", onPressed: _deleteKey, color: Colors.red[900]),
],
),
),
);
}
Widget _buildForeignKeyTokenView(BuildContext context, KeyTokenPreview keytoken) {
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: 'KeyTokenID',
values: [keytoken.keytokenID],
),
_buildNameCard(context, false),
_buildOwnerCard(context, false),
..._buildPermissionCard(context, false, keytoken),
],
),
),
);
}
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: [keytokenPreview!.ownerUserID + (isOwned ? ' (you)' : ''), if (snapshot.data?.username != null) snapshot.data!.username!],
);
} else {
return UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidUser,
title: 'Owner',
values: [keytokenPreview!.ownerUserID + (isOwned ? ' (you)' : '')],
);
}
},
);
}
Widget _buildNameCard(BuildContext context, bool isOwned) {
if (_editName == 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: _ctrlName,
decoration: new InputDecoration.collapsed(hintText: 'Name'),
),
),
SizedBox(width: 12),
SizedBox(width: 4),
IconButton(icon: FaIcon(FontAwesomeIcons.solidFloppyDisk), onPressed: _saveName),
],
),
),
);
} else if (_editName == EditState.none) {
return UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidInputText,
title: 'Name',
values: [_nameOverride ?? keytokenPreview!.name],
iconActions: isOwned ? [(FontAwesomeIcons.penToSquare, null, _showEditName)] : [],
);
} else if (_editName == 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: $_editName';
}
}
void _showEditName() {
setState(() {
_ctrlName.text = _nameOverride ?? keytokenPreview?.name ?? '';
_editName = EditState.editing;
});
}
void _saveName() async {
final userAcc = Provider.of<AppAuth>(context, listen: false);
final newName = _ctrlName.text;
try {
setState(() {
_editName = EditState.saving;
});
final newKeyToken = await APIClient.updateKeyToken(userAcc, widget.keytokenID, name: newName);
setState(() {
_editName = EditState.none;
_nameOverride = newKeyToken.name;
});
widget.needsReload?.call();
} catch (exc, trace) {
ApplicationLog.error('Failed to save DisplayName: ' + exc.toString(), trace: trace);
Toaster.error("Error", 'Failed to save DisplayName');
}
}
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, keytokenPreview!.ownerUserID);
//await Future.delayed(const Duration(seconds: 10), () {});
return owner;
} finally {
_incLoadingIndeterminateCounter(-1);
}
}
void _incLoadingIndeterminateCounter(int delta) {
setState(() {
_loadingIndeterminateCounter += delta;
AppBarState().setLoadingIndeterminate(_loadingIndeterminateCounter > 0);
});
}
List<Widget> _buildPermissionCard(BuildContext context, bool isOwned, KeyTokenPreview keyToken) {
Widget w1;
Widget w2;
if (isOwned) {
w1 = UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidShieldKeyhole,
title: 'Permissions',
values: _formatPermissions(keyToken.permissions),
iconActions: [(FontAwesomeIcons.penToSquare, null, _editPermissions)],
);
} else {
w1 = UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidShieldKeyhole,
title: 'Permissions',
values: _formatPermissions(keyToken.permissions),
);
}
w2 = FutureBuilder(
future: _futureAllChannels.future,
builder: (context, snapshot) {
if (snapshot.hasData) {
var cmap = snapshot.data!;
if (isOwned) {
return UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidSnake,
title: 'Channels',
values: (keyToken.allChannels) ? (['All Channels']) : (keyToken.channels.isEmpty ? ['(None)'] : (keyToken.channels.map((c) => cmap[c]?.displayName ?? c).toList())),
iconActions: [(FontAwesomeIcons.penToSquare, null, _editChannels)],
);
} else {
return UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidSnake,
title: 'Channels',
values: (keyToken.allChannels) ? (['All Channels']) : (keyToken.channels.isEmpty ? ['(None)'] : (keyToken.channels.map((c) => cmap[c]?.displayName ?? c).toList())),
);
}
} else {
if (isOwned) {
return UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidSnake,
title: 'Channels',
values: (keyToken.allChannels) ? ['All Channels'] : (keyToken.channels.isEmpty ? ['(None)'] : keyToken.channels),
iconActions: [(FontAwesomeIcons.penToSquare, null, _editChannels)],
);
} else {
return UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidSnake,
title: 'Channels',
values: (keyToken.allChannels) ? ['All Channels'] : (keyToken.channels.isEmpty ? ['(None)'] : keyToken.channels),
);
}
}
},
);
return [w1, w2];
}
List<String> _formatPermissions(String v) {
var splt = v.split(';');
if (splt.length == 0) return ["None"];
List<String> result = [];
if (splt.contains("A")) result.add("Admin");
if (splt.contains("UR")) result.add("Read Account");
if (splt.contains("CR")) result.add("Read Messages");
if (splt.contains("CS")) result.add("Send Messages");
return result;
}
void _editPermissions() async {
if (keytokenUserAccAdmin == null || keytokenUserAccAdmin!.keytokenID == keytokenPreview!.keytokenID) {
Toaster.error("Error", "You cannot edit the currently used token");
return;
}
if (keytokenUserAccSend == null || keytokenUserAccSend!.keytokenID == keytokenPreview!.keytokenID) {
Toaster.error("Error", "You cannot edit the currently used token");
return;
}
await showDialog<void>(
context: context,
builder: (context) => EditKeyTokenPermissionsDialog(
keytoken: keytokenPreview!,
onUpdatePermissions: _updatePermissions,
),
);
}
void _editChannels() async {
if (keytokenUserAccAdmin == null || keytokenUserAccAdmin!.keytokenID == keytokenPreview!.keytokenID) {
Toaster.error("Error", "You cannot edit the currently used token");
return;
}
if (keytokenUserAccSend == null || keytokenUserAccSend!.keytokenID == keytokenPreview!.keytokenID) {
Toaster.error("Error", "You cannot edit the currently used token");
return;
}
var ownChannels = (await _futureOwnedChannels.future);
ownChannels.sort((a, b) => a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()));
await showDialog<void>(
context: context,
builder: (context) => EditKeyTokenChannelsDialog(
ownedChannels: ownChannels,
keytoken: keytokenPreview!,
onUpdateChannels: _updateChannelsSelected,
onUpdateSetAllChannels: _updateChannelsAll,
),
);
}
void _deleteKey() async {
final acc = Provider.of<AppAuth>(context, listen: false);
if (keytokenUserAccAdmin == null || keytokenUserAccAdmin!.keytokenID == keytokenPreview!.keytokenID) {
Toaster.error("Error", "You cannot delete the currently used token");
return;
}
if (keytokenUserAccSend == null || keytokenUserAccSend!.keytokenID == keytokenPreview!.keytokenID) {
Toaster.error("Error", "You cannot delete the currently used token");
return;
}
try {
final r = await UIDialogs.showConfirmDialog(context, 'Really (permanently) delete this Key?', okText: 'Delete', cancelText: 'Cancel');
if (!r) return;
await APIClient.deleteKeyToken(acc, keytokenPreview!.keytokenID);
widget.needsReload?.call();
Toaster.info('Logout', 'Successfully deleted the key');
Navi.pop(context);
} catch (exc, trace) {
Toaster.error("Error", 'Failed to delete key');
ApplicationLog.error('Failed to delete key: ' + exc.toString(), trace: trace);
}
}
void _updateChannelsSelected(Set<String> selectedEntries) async {
final acc = Provider.of<AppAuth>(context, listen: false);
try {
final r = await APIClient.updateKeyToken(acc, widget.keytokenID, channels: selectedEntries.toList(), allChannels: false);
setState(() {
keytoken = r;
keytokenPreview = r.toPreview();
});
Toaster.info("Success", "Key updated");
widget.needsReload?.call();
} catch (exc, trace) {
ApplicationLog.error('Failed to update key: ' + exc.toString(), trace: trace);
Toaster.error("Error", 'Failed to update key');
}
}
void _updateChannelsAll() async {
final acc = Provider.of<AppAuth>(context, listen: false);
try {
final r = await APIClient.updateKeyToken(acc, widget.keytokenID, channels: [], allChannels: true);
setState(() {
keytoken = r;
keytokenPreview = r.toPreview();
});
Toaster.info("Success", "Key updated");
widget.needsReload?.call();
} catch (exc, trace) {
ApplicationLog.error('Failed to update key: ' + exc.toString(), trace: trace);
Toaster.error("Error", 'Failed to update key');
}
}
void _updatePermissions(String perm) async {
final acc = Provider.of<AppAuth>(context, listen: false);
try {
final r = await APIClient.updateKeyToken(acc, widget.keytokenID, permissions: perm);
setState(() {
keytoken = r;
keytokenPreview = r.toPreview();
});
Toaster.info("Success", "Key updated");
widget.needsReload?.call();
} catch (exc, trace) {
ApplicationLog.error('Failed to update key: ' + exc.toString(), trace: trace);
Toaster.error("Error", 'Failed to update key');
}
}
}

View File

@ -3,6 +3,7 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart';
enum MessageFilterChipletType { enum MessageFilterChipletType {
search, search,
plainSearch,
channel, channel,
sender, sender,
timeRange, timeRange,
@ -21,6 +22,8 @@ class MessageFilterChiplet {
switch (type) { switch (type) {
case MessageFilterChipletType.search: case MessageFilterChipletType.search:
return FontAwesomeIcons.magnifyingGlass; return FontAwesomeIcons.magnifyingGlass;
case MessageFilterChipletType.plainSearch:
return FontAwesomeIcons.magnifyingGlassPlus;
case MessageFilterChipletType.channel: case MessageFilterChipletType.channel:
return FontAwesomeIcons.snake; return FontAwesomeIcons.snake;
case MessageFilterChipletType.sender: case MessageFilterChipletType.sender:

View File

@ -6,7 +6,7 @@ import 'package:simplecloudnotifier/models/channel.dart';
import 'package:simplecloudnotifier/models/scn_message.dart'; import 'package:simplecloudnotifier/models/scn_message.dart';
import 'package:simplecloudnotifier/pages/message_list/message_filter_chiplet.dart'; import 'package:simplecloudnotifier/pages/message_list/message_filter_chiplet.dart';
import 'package:simplecloudnotifier/pages/message_view/message_view.dart'; import 'package:simplecloudnotifier/pages/message_view/message_view.dart';
import 'package:simplecloudnotifier/settings/app_settings.dart'; import 'package:simplecloudnotifier/state/app_settings.dart';
import 'package:simplecloudnotifier/state/app_bar_state.dart'; import 'package:simplecloudnotifier/state/app_bar_state.dart';
import 'package:simplecloudnotifier/state/app_events.dart'; import 'package:simplecloudnotifier/state/app_events.dart';
import 'package:simplecloudnotifier/state/application_log.dart'; import 'package:simplecloudnotifier/state/application_log.dart';
@ -30,6 +30,7 @@ class _MessageListPageState extends State<MessageListPage> with RouteAware {
PagingController<String, SCNMessage> _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; Map<String, Channel>? _channels = null;
bool _channelsFetched = false;
bool _isInitialized = false; bool _isInitialized = false;
@ -72,6 +73,9 @@ class _MessageListPageState extends State<MessageListPage> with RouteAware {
_channels = SCNDataCache().getChannelMap(); _channels = SCNDataCache().getChannelMap();
//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); _pagingController.value = PagingState(nextPageKey: null, itemList: SCNDataCache().getMessagesSorted(), error: null);
_backgroundRefresh(true); _backgroundRefresh(true);
@ -132,9 +136,12 @@ class _MessageListPageState extends State<MessageListPage> with RouteAware {
} }
try { try {
if (_channels == null) { if (_channels == null || !_channelsFetched) {
final channels = await APIClient.getChannelList(acc, ChannelSelector.allAny); final channels = await APIClient.getChannelList(acc, ChannelSelector.allAny);
_channels = <String, Channel>{for (var v in channels) v.channel.channelID: v.channel}; setState(() {
_channels = <String, Channel>{for (var v in channels) v.channel.channelID: v.channel};
_channelsFetched = true;
});
SCNDataCache().setChannelCache(channels); // no await SCNDataCache().setChannelCache(channels); // no await
} }
@ -311,6 +318,11 @@ class _MessageListPageState extends State<MessageListPage> with RouteAware {
filter.searchFilter = chipletsSearch.map((p) => p.value as String).toList(); filter.searchFilter = chipletsSearch.map((p) => p.value as String).toList();
} }
var chipletsPlainSearch = _filterChiplets.where((p) => p.type == MessageFilterChipletType.plainSearch).toList();
if (chipletsPlainSearch.isNotEmpty) {
filter.plainSearchFilter = chipletsPlainSearch.map((p) => p.value as String).toList();
}
var chipletsKeyTokens = _filterChiplets.where((p) => p.type == MessageFilterChipletType.sendkey).toList(); var chipletsKeyTokens = _filterChiplets.where((p) => p.type == MessageFilterChipletType.sendkey).toList();
if (chipletsKeyTokens.isNotEmpty) { if (chipletsKeyTokens.isNotEmpty) {
filter.usedKeys = chipletsKeyTokens.map((p) => p.value as String).toList(); filter.usedKeys = chipletsKeyTokens.map((p) => p.value as String).toList();
@ -326,6 +338,13 @@ class _MessageListPageState extends State<MessageListPage> with RouteAware {
filter.senderNames = chipletSender.map((p) => p.value as String).toList(); filter.senderNames = chipletSender.map((p) => p.value as String).toList();
} }
var chipletsTimeRange = _filterChiplets.where((p) => p.type == MessageFilterChipletType.timeRange).toList();
if (chipletsTimeRange.isNotEmpty) {
//TODO
//filter.timeAfter = chipletsTimeRange[0].value1 as DateTime;
//filter.timeBefore = chipletsTimeRange[0].value2 as DateTime;
}
return filter; return filter;
} }
} }

View File

@ -2,15 +2,14 @@ import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:provider/provider.dart';
import 'package:simplecloudnotifier/models/channel.dart'; import 'package:simplecloudnotifier/models/channel.dart';
import 'package:simplecloudnotifier/models/scn_message.dart'; import 'package:simplecloudnotifier/models/scn_message.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:simplecloudnotifier/state/app_settings.dart';
import 'package:simplecloudnotifier/utils/ui.dart'; import 'package:simplecloudnotifier/utils/ui.dart';
class MessageListItem extends StatelessWidget { class MessageListItem extends StatelessWidget {
static final _dateFormat = DateFormat('yyyy-MM-dd kk:mm');
static final _lineCount = 3; //TODO setting
const MessageListItem({ const MessageListItem({
required this.message, required this.message,
required this.allChannels, required this.allChannels,
@ -32,6 +31,9 @@ class MessageListItem extends StatelessWidget {
} }
Card buildWithoutChannel(BuildContext context) { Card buildWithoutChannel(BuildContext context) {
final dateFormat = context.select<AppSettings, AppSettingsDateFormat>((v) => v.dateFormat).dateFormat();
final previewLineCount = context.select<AppSettings, int>((v) => v.messagePreviewLength);
return Card.filled( return Card.filled(
margin: EdgeInsets.fromLTRB(0, 4, 0, 4), margin: EdgeInsets.fromLTRB(0, 4, 0, 4),
shape: BeveledRectangleBorder(borderRadius: BorderRadius.circular(0)), shape: BeveledRectangleBorder(borderRadius: BorderRadius.circular(0)),
@ -57,7 +59,7 @@ class MessageListItem extends StatelessWidget {
), ),
), ),
Text( Text(
_dateFormat.format(DateTime.parse(message.timestamp).toLocal()), dateFormat.format(DateTime.parse(message.timestamp).toLocal()),
style: const TextStyle(fontWeight: FontWeight.normal, fontSize: 11), style: const TextStyle(fontWeight: FontWeight.normal, fontSize: 11),
overflow: TextOverflow.clip, overflow: TextOverflow.clip,
maxLines: 1, maxLines: 1,
@ -70,10 +72,10 @@ class MessageListItem extends StatelessWidget {
children: [ children: [
Expanded( Expanded(
child: Text( child: Text(
processContent(message.content), processContent(message.content, previewLineCount),
style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(160)), style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(160)),
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
maxLines: _lineCount, maxLines: previewLineCount,
), ),
), ),
if (message.priority == 2) SizedBox(width: 4), if (message.priority == 2) SizedBox(width: 4),
@ -90,6 +92,9 @@ class MessageListItem extends StatelessWidget {
} }
Card buildWithChannel(BuildContext context) { Card buildWithChannel(BuildContext context) {
final dateFormat = context.select<AppSettings, AppSettingsDateFormat>((v) => v.dateFormat).dateFormat();
final previewLineCount = context.select<AppSettings, int>((v) => v.messagePreviewLength);
return Card.filled( return Card.filled(
margin: EdgeInsets.fromLTRB(0, 4, 0, 4), margin: EdgeInsets.fromLTRB(0, 4, 0, 4),
shape: BeveledRectangleBorder(borderRadius: BorderRadius.circular(0)), shape: BeveledRectangleBorder(borderRadius: BorderRadius.circular(0)),
@ -113,7 +118,7 @@ class MessageListItem extends StatelessWidget {
), ),
Expanded(child: SizedBox()), Expanded(child: SizedBox()),
Text( Text(
_dateFormat.format(DateTime.parse(message.timestamp).toLocal()), dateFormat.format(DateTime.parse(message.timestamp).toLocal()),
style: const TextStyle(fontWeight: FontWeight.normal, fontSize: 11), style: const TextStyle(fontWeight: FontWeight.normal, fontSize: 11),
overflow: TextOverflow.clip, overflow: TextOverflow.clip,
maxLines: 1, maxLines: 1,
@ -132,10 +137,10 @@ class MessageListItem extends StatelessWidget {
children: [ children: [
Expanded( Expanded(
child: Text( child: Text(
processContent(message.content), processContent(message.content, previewLineCount),
style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(160)), style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(160)),
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
maxLines: _lineCount, maxLines: previewLineCount,
), ),
), ),
if (message.priority == 2) SizedBox(width: 4), if (message.priority == 2) SizedBox(width: 4),
@ -151,7 +156,7 @@ class MessageListItem extends StatelessWidget {
); );
} }
String processContent(String? v) { String processContent(String? v, int lineCount) {
if (v == null) { if (v == null) {
return ''; return '';
} }
@ -161,7 +166,7 @@ class MessageListItem extends StatelessWidget {
return ''; return '';
} }
return lines.sublist(0, min(_lineCount, lines.length)).join("\n").trim(); return lines.sublist(0, min(lineCount, lines.length)).join("\n").trim();
} }
String processTitle(String? v) { String processTitle(String? v) {

View File

@ -5,14 +5,18 @@ import 'package:intl/intl.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
import 'package:simplecloudnotifier/api/api_client.dart'; import 'package:simplecloudnotifier/api/api_client.dart';
import 'package:simplecloudnotifier/components/error_display/error_display.dart';
import 'package:simplecloudnotifier/components/layout/scaffold.dart'; import 'package:simplecloudnotifier/components/layout/scaffold.dart';
import 'package:simplecloudnotifier/models/channel.dart'; import 'package:simplecloudnotifier/models/channel.dart';
import 'package:simplecloudnotifier/models/keytoken.dart'; import 'package:simplecloudnotifier/models/keytoken.dart';
import 'package:simplecloudnotifier/models/scn_message.dart'; import 'package:simplecloudnotifier/models/scn_message.dart';
import 'package:simplecloudnotifier/models/user.dart'; import 'package:simplecloudnotifier/models/user.dart';
import 'package:simplecloudnotifier/pages/channel_view/channel_view.dart'; import 'package:simplecloudnotifier/pages/channel_view/channel_view.dart';
import 'package:simplecloudnotifier/pages/filtered_message_view/filtered_message_view.dart';
import 'package:simplecloudnotifier/pages/keytoken_view/keytoken_view.dart';
import 'package:simplecloudnotifier/state/app_auth.dart'; import 'package:simplecloudnotifier/state/app_auth.dart';
import 'package:simplecloudnotifier/state/app_bar_state.dart'; import 'package:simplecloudnotifier/state/app_bar_state.dart';
import 'package:simplecloudnotifier/state/app_settings.dart';
import 'package:simplecloudnotifier/utils/navi.dart'; import 'package:simplecloudnotifier/utils/navi.dart';
import 'package:simplecloudnotifier/utils/toaster.dart'; import 'package:simplecloudnotifier/utils/toaster.dart';
import 'package:simplecloudnotifier/utils/ui.dart'; import 'package:simplecloudnotifier/utils/ui.dart';
@ -34,7 +38,8 @@ class MessageViewPage extends StatefulWidget {
class _MessageViewPageState extends State<MessageViewPage> { class _MessageViewPageState extends State<MessageViewPage> {
late Future<(SCNMessage, ChannelPreview, KeyTokenPreview, UserPreview)>? mainFuture; late Future<(SCNMessage, ChannelPreview, KeyTokenPreview, UserPreview)>? mainFuture;
(SCNMessage, ChannelPreview, KeyTokenPreview, UserPreview)? mainFutureSnapshot = null; (SCNMessage, ChannelPreview, KeyTokenPreview, UserPreview)? mainFutureSnapshot = null;
static final _dateFormat = DateFormat('yyyy-MM-dd kk:mm');
final ScrollController _controller = ScrollController();
bool _monospaceMode = false; bool _monospaceMode = false;
@ -61,7 +66,7 @@ class _MessageViewPageState extends State<MessageViewPage> {
final msg = await APIClient.getMessage(acc, widget.messageID); final msg = await APIClient.getMessage(acc, widget.messageID);
final fut_chn = APIClient.getChannelPreview(acc, msg.channelID); final fut_chn = APIClient.getChannelPreview(acc, msg.channelID);
final fut_key = APIClient.getKeyTokenPreview(acc, msg.usedKeyID); final fut_key = APIClient.getKeyTokenPreviewByID(acc, msg.usedKeyID);
final fut_usr = APIClient.getUserPreview(acc, msg.senderUserID); final fut_usr = APIClient.getUserPreview(acc, msg.senderUserID);
final chn = await fut_chn; final chn = await fut_chn;
@ -82,6 +87,7 @@ class _MessageViewPageState extends State<MessageViewPage> {
@override @override
void dispose() { void dispose() {
_controller.dispose();
super.dispose(); super.dispose();
} }
@ -99,7 +105,7 @@ class _MessageViewPageState extends State<MessageViewPage> {
final (msg, chn, tok, usr) = snapshot.data!; final (msg, chn, tok, usr) = snapshot.data!;
return _buildMessageView(context, msg, chn, tok, usr); return _buildMessageView(context, msg, chn, tok, usr);
} else if (snapshot.hasError) { } else if (snapshot.hasError) {
return Center(child: Text('${snapshot.error}')); //TODO nice error page return ErrorDisplay(errorMessage: '${snapshot.error}');
} else if (message != null && !this.message!.trimmed) { } else if (message != null && !this.message!.trimmed) {
return _buildMessageView(context, this.message!, null, null, null); return _buildMessageView(context, this.message!, null, null, null);
} else { } else {
@ -136,73 +142,104 @@ class _MessageViewPageState extends State<MessageViewPage> {
Widget _buildMessageView(BuildContext context, SCNMessage 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); final userAccUserID = context.select<AppAuth, String?>((v) => v.userID);
return SingleChildScrollView( final child = Padding(
child: Padding( padding: const EdgeInsets.fromLTRB(24, 16, 24, 16),
padding: const EdgeInsets.fromLTRB(24, 16, 24, 16), child: Column(
child: Column( crossAxisAlignment: CrossAxisAlignment.stretch,
crossAxisAlignment: CrossAxisAlignment.stretch, children: [
children: [ ..._buildMessageHeader(context, message, channel),
..._buildMessageHeader(context, message, channel), SizedBox(height: 8),
SizedBox(height: 8), if (message.content != null) ..._buildMessageContent(context, message),
if (message.content != null) ..._buildMessageContent(context, message), SizedBox(height: 8),
SizedBox(height: 8), if (message.senderName != null)
if (message.senderName != null)
UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidSignature,
title: 'Sender',
values: [message.senderName!],
mainAction: () => {/*TODO*/},
),
UI.metaCard( UI.metaCard(
context: context, context: context,
icon: FontAwesomeIcons.solidGearCode, icon: FontAwesomeIcons.solidSignature,
title: 'KeyToken', title: 'Sender',
values: [message.usedKeyID, token?.name ?? '...'], values: [message.senderName!],
mainAction: () => {/*TODO*/}, mainAction: () => {
Navi.push(context, () => FilteredMessageViewPage(title: message.senderName!, filter: MessageFilter(senderNames: [message.senderName!])))
},
), ),
UI.metaCard( UI.metaCard(
context: context, context: context,
icon: FontAwesomeIcons.solidIdCardClip, icon: FontAwesomeIcons.solidGearCode,
title: 'MessageID', title: 'KeyToken',
values: [message.messageID, message.userMessageID ?? ''], values: [message.usedKeyID, token?.name ?? '...'],
), mainAction: () {
UI.metaCard( if (message.senderUserID == userAccUserID) {
context: context, Navi.push(context, () => KeyTokenViewPage(keytokenID: message.usedKeyID, preloadedData: null, needsReload: null));
icon: FontAwesomeIcons.solidSnake, } else {
title: 'Channel', Navi.push(context, () => FilteredMessageViewPage(title: token?.name ?? message.usedKeyID, filter: MessageFilter(usedKeys: [message.usedKeyID])));
values: [message.channelID, channel?.displayName ?? message.channelInternalName], }
mainAction: (channel != null) },
? () { ),
Navi.push(context, () => ChannelViewPage(channelID: channel.channelID, preloadedData: null, needsReload: null)); UI.metaCard(
} context: context,
: null, icon: FontAwesomeIcons.solidIdCardClip,
), title: 'MessageID',
UI.metaCard( values: [message.messageID, message.userMessageID ?? ''],
context: context, ),
icon: FontAwesomeIcons.solidTimer, UI.metaCard(
title: 'Timestamp', context: context,
values: [message.timestamp], icon: FontAwesomeIcons.solidSnake,
), title: 'Channel',
UI.metaCard( values: [message.channelID, channel?.displayName ?? message.channelInternalName],
context: context, mainAction: (channel != null)
icon: FontAwesomeIcons.solidUser, ? () {
title: 'User', Navi.push(context, () => ChannelViewPage(channelID: channel.channelID, preloadedData: null, needsReload: null));
values: [user?.userID ?? '...', user?.username ?? ''], }
mainAction: () => {/*TODO*/}, : null,
), ),
UI.metaCard( UI.metaCard(
context: context, context: context,
icon: FontAwesomeIcons.solidBolt, icon: FontAwesomeIcons.solidTimer,
title: 'Priority', title: 'Timestamp',
values: [_prettyPrintPriority(message.priority)], values: [message.timestamp],
mainAction: () => {/*TODO*/}, ),
), UI.metaCard(
if (message.senderUserID == userAccUserID) UI.button(text: "Delete Message", onPressed: () {/*TODO*/}, color: Colors.red[900]), context: context,
], icon: FontAwesomeIcons.solidUser,
), title: 'User',
values: [user?.userID ?? message.senderUserID, user?.username ?? ''],
mainAction: () => Navi.push(context, () => FilteredMessageViewPage(title: user?.username ?? message.senderUserID, filter: MessageFilter(senderUserID: [message.senderUserID]))),
),
UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidBolt,
title: 'Priority',
values: [_prettyPrintPriority(message.priority)],
mainAction: () => Navi.push(context, () => FilteredMessageViewPage(title: "Priority ${message.priority}", filter: MessageFilter(priority: [message.priority]))),
),
if (message.senderUserID == userAccUserID) UI.button(text: "Delete Message", onPressed: () {/*TODO*/}, color: Colors.red[900]),
],
), ),
); );
var showScrollbar = false;
if (!_monospaceMode && (message.content ?? '').length > 4096) showScrollbar = true;
if (_monospaceMode && (message.content ?? '').split('\n').length > 64) showScrollbar = true;
if (showScrollbar) {
return Padding(
padding: const EdgeInsets.fromLTRB(0, 0, 6, 0),
child: Scrollbar(
thickness: 12.0,
radius: Radius.circular(6),
thumbVisibility: false,
interactive: true,
controller: _controller,
child: SingleChildScrollView(
controller: _controller,
child: child,
),
),
);
} else {
return SingleChildScrollView(
child: child,
);
}
} }
String _resolveChannelName(ChannelPreview? channel, SCNMessage message) { String _resolveChannelName(ChannelPreview? channel, SCNMessage message) {
@ -210,6 +247,8 @@ class _MessageViewPageState extends State<MessageViewPage> {
} }
List<Widget> _buildMessageHeader(BuildContext context, SCNMessage message, ChannelPreview? channel) { List<Widget> _buildMessageHeader(BuildContext context, SCNMessage message, ChannelPreview? channel) {
final dateFormat = context.select<AppSettings, AppSettingsDateFormat>((v) => v.dateFormat).dateFormat();
return [ return [
Row( Row(
children: [ children: [
@ -220,7 +259,7 @@ class _MessageViewPageState extends State<MessageViewPage> {
fontSize: 16, fontSize: 16,
), ),
Expanded(child: SizedBox()), Expanded(child: SizedBox()),
Text(_dateFormat.format(DateTime.parse(message.timestamp)), style: const TextStyle(fontSize: 14)), Text(dateFormat.format(DateTime.parse(message.timestamp)), style: const TextStyle(fontSize: 14)),
], ],
), ),
SizedBox(height: 8), SizedBox(height: 8),

View File

@ -1,12 +1,20 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:qr_flutter/qr_flutter.dart'; import 'package:qr_flutter/qr_flutter.dart';
import 'package:simplecloudnotifier/api/api_client.dart';
import 'package:simplecloudnotifier/components/error_display/error_display.dart';
import 'package:simplecloudnotifier/state/application_log.dart'; import 'package:simplecloudnotifier/state/application_log.dart';
import 'package:simplecloudnotifier/state/globals.dart';
import 'package:simplecloudnotifier/utils/toaster.dart';
import 'package:simplecloudnotifier/utils/ui.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:simplecloudnotifier/state/app_auth.dart'; import 'package:simplecloudnotifier/state/app_auth.dart';
class SendRootPage extends StatefulWidget { class SendRootPage extends StatefulWidget {
const SendRootPage({super.key, required bool isVisiblePage}); const SendRootPage({super.key, required this.isVisiblePage});
final bool isVisiblePage;
@override @override
State<SendRootPage> createState() => _SendRootPageState(); State<SendRootPage> createState() => _SendRootPageState();
@ -15,18 +23,28 @@ class SendRootPage extends StatefulWidget {
class _SendRootPageState extends State<SendRootPage> { class _SendRootPageState extends State<SendRootPage> {
late TextEditingController _msgTitle; late TextEditingController _msgTitle;
late TextEditingController _msgContent; late TextEditingController _msgContent;
late TextEditingController _channelName;
late TextEditingController _senderName;
int _priority = 0;
bool _expanded = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_msgTitle = TextEditingController(); _msgTitle = TextEditingController();
_msgContent = TextEditingController(); _msgContent = TextEditingController();
_channelName = TextEditingController();
_senderName = TextEditingController();
} }
@override @override
void dispose() { void dispose() {
_msgTitle.dispose(); _msgTitle.dispose();
_msgContent.dispose(); _msgContent.dispose();
_channelName.dispose();
_senderName.dispose();
super.dispose(); super.dispose();
} }
@ -37,52 +55,162 @@ class _SendRootPageState extends State<SendRootPage> {
return SingleChildScrollView( return SingleChildScrollView(
child: Padding( child: Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: Column( child: _expanded ? _buildExpanded(context, acc) : _buildSimple(context, acc),
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildQRCode(context, acc),
const SizedBox(height: 16),
FractionallySizedBox(
widthFactor: 1.0,
child: TextField(
controller: _msgTitle,
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: 'Title',
),
),
),
const SizedBox(height: 16),
FractionallySizedBox(
widthFactor: 1.0,
child: TextField(
controller: _msgContent,
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: 'Text',
),
minLines: 2,
maxLines: null,
keyboardType: TextInputType.multiline,
),
),
const SizedBox(height: 16),
FilledButton(
style: FilledButton.styleFrom(textStyle: const TextStyle(fontSize: 20)),
onPressed: _send,
child: const Text('Send'),
),
const SizedBox(height: 32),
],
),
), ),
); );
}, },
); );
} }
void _send() { Widget _buildSimple(BuildContext context, AppAuth acc) {
//... return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildQRCode(context, acc),
const SizedBox(height: 16),
FractionallySizedBox(
widthFactor: 1.0,
child: TextField(
controller: _msgTitle,
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: 'Title',
),
),
),
const SizedBox(height: 16),
FractionallySizedBox(
widthFactor: 1.0,
child: TextField(
controller: _msgContent,
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: 'Text',
),
minLines: 2,
maxLines: null,
keyboardType: TextInputType.multiline,
),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: UI.button(
text: 'Send',
onPressed: () {
_sendSimple(acc);
},
color: Theme.of(context).colorScheme.primary,
textColor: Theme.of(context).colorScheme.onPrimary,
),
),
const SizedBox(width: 8),
UI.buttonIconOnly(
icon: FontAwesomeIcons.layerPlus,
onPressed: _openExpanded,
square: true,
color: Theme.of(context).colorScheme.secondary,
iconColor: Theme.of(context).colorScheme.onSecondary,
),
],
),
const SizedBox(height: 32),
],
);
}
Widget _buildExpanded(BuildContext context, AppAuth acc) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
FractionallySizedBox(
widthFactor: 1.0,
child: TextField(
controller: _channelName,
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: 'Channel',
),
),
),
const SizedBox(height: 16),
FractionallySizedBox(
widthFactor: 1.0,
child: TextField(
controller: _msgTitle,
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: 'Title',
),
),
),
const SizedBox(height: 16),
FractionallySizedBox(
widthFactor: 1.0,
child: TextField(
controller: _senderName,
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: 'Sender',
),
),
),
const SizedBox(height: 16),
SegmentedButton<int>(
showSelectedIcon: false,
segments: const <ButtonSegment<int>>[
ButtonSegment<int>(value: 0, label: Text('Low Priority')),
ButtonSegment<int>(value: 1, label: Text('Normal')),
ButtonSegment<int>(value: 2, label: Text('High Priority')),
],
selected: {_priority},
onSelectionChanged: (Set<int> newSelection) {
setState(() {
_priority = newSelection.isEmpty ? 1 : newSelection.first;
});
},
),
const SizedBox(height: 16),
FractionallySizedBox(
widthFactor: 1.0,
child: TextField(
controller: _msgContent,
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: 'Text',
),
minLines: 6,
maxLines: null,
keyboardType: TextInputType.multiline,
),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: UI.button(
text: 'Send',
onPressed: () {
_sendExpanded(acc);
},
color: Theme.of(context).colorScheme.primary,
textColor: Theme.of(context).colorScheme.onPrimary,
),
),
const SizedBox(width: 8),
UI.buttonIconOnly(
icon: FontAwesomeIcons.squareDashed,
onPressed: _closeExpanded,
square: true,
color: Theme.of(context).colorScheme.secondary,
iconColor: Theme.of(context).colorScheme.onSecondary,
),
],
),
const SizedBox(height: 32),
],
);
} }
Widget _buildQRCode(BuildContext context, AppAuth acc) { Widget _buildQRCode(BuildContext context, AppAuth acc) {
@ -93,39 +221,82 @@ class _SendRootPageState extends State<SendRootPage> {
return FutureBuilder( return FutureBuilder(
future: acc.loadUser(force: false), future: acc.loadUser(force: false),
builder: ((context, snapshot) { builder: ((context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) { if (snapshot.connectionState == ConnectionState.active || snapshot.connectionState == ConnectionState.waiting) {
if (snapshot.hasError) { return const SizedBox(
return Text('Error: ${snapshot.error}'); //TODO better error display width: 300.0,
} height: 300.0,
var url = (acc.tokenSend == null) ? 'https://simplecloudnotifier.de?preset_user_id=${acc.userID}' : 'https://simplecloudnotifier.de?preset_user_id=${acc.userID}&preset_user_key=${acc.tokenSend}'; child: Center(child: CircularProgressIndicator()),
return GestureDetector(
onTap: () {
_openWeb(url);
},
child: QrImageView(
data: url,
version: QrVersions.auto,
size: 300.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,
),
),
); );
} }
return const SizedBox( if (snapshot.hasError) {
width: 300.0, return ErrorDisplay(errorMessage: '${snapshot.error}');
height: 300.0, }
child: Center(child: CircularProgressIndicator()), if (snapshot.connectionState != ConnectionState.done) {
return Text('...'); //?
}
var url = (acc.tokenSend == null) ? 'https://simplecloudnotifier.de?preset_user_id=${acc.userID}' : 'https://simplecloudnotifier.de?preset_user_id=${acc.userID}&preset_user_key=${acc.tokenSend}';
return GestureDetector(
onTap: () {
_openWeb(url);
},
child: QrImageView(
data: url,
version: QrVersions.auto,
size: 300.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,
),
),
); );
}), }),
); );
} }
void _sendSimple(AppAuth acc) async {
if (!acc.isAuth()) {
Toaster.error("Error", 'Must be logged in to send messages');
return;
}
try {
await APIClient.sendMessage(acc.userID!, acc.tokenSend!, _msgContent.text);
Toaster.success("Success", 'Message sent');
setState(() {
_msgTitle.clear();
_msgContent.clear();
});
} catch (e, stackTrace) {
Toaster.error("Error", 'Failed to send message: ${e.toString()}');
ApplicationLog.error('Failed to send message', trace: stackTrace);
}
}
void _sendExpanded(AppAuth acc) async {
if (!acc.isAuth()) {
Toaster.error("Error", 'Must be logged in to send messages');
return;
}
try {
await APIClient.sendMessage(acc.userID!, acc.tokenSend!, _msgContent.text, channel: _channelName.text, senderName: _senderName.text, priority: _priority);
Toaster.success("Success", 'Message sent');
setState(() {
_msgTitle.clear();
_msgContent.clear();
});
} catch (e, stackTrace) {
Toaster.error("Error", 'Failed to send message: ${e.toString()}');
ApplicationLog.error('Failed to send message', trace: stackTrace);
}
}
void _openWeb(String url) async { void _openWeb(String url) async {
try { try {
final Uri uri = Uri.parse(url); final Uri uri = Uri.parse(url);
@ -135,10 +306,30 @@ class _SendRootPageState extends State<SendRootPage> {
if (await canLaunchUrl(uri)) { if (await canLaunchUrl(uri)) {
await launchUrl(uri); await launchUrl(uri);
} else { } else {
// TODO ("Cannot open URL"); Toaster.error("Error", 'Cannot open URL on this system');
} }
} catch (exc, trace) { } catch (exc, trace) {
ApplicationLog.error('Failed to open URL: ' + exc.toString(), additional: 'URL: ${url}', trace: trace); ApplicationLog.error('Failed to open URL: ' + exc.toString(), additional: 'URL: ${url}', trace: trace);
} }
} }
void _closeExpanded() {
setState(() {
_expanded = false;
_channelName.clear();
_priority = 1;
_senderName.clear();
});
}
void _openExpanded() {
final userAcc = Provider.of<AppAuth>(context, listen: false);
setState(() {
_expanded = true;
_channelName.text = userAcc.getUserOrNull()?.defaultChannel ?? 'main';
_priority = 1;
_senderName.text = Globals().deviceName;
});
}
} }

View File

@ -0,0 +1,86 @@
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/sender_name_statistics.dart';
import 'package:simplecloudnotifier/state/application_log.dart';
import 'package:simplecloudnotifier/state/app_auth.dart';
import 'package:simplecloudnotifier/pages/sender_list/sender_list_item.dart';
class SenderListPage extends StatefulWidget {
const SenderListPage({super.key});
@override
State<SenderListPage> createState() => _SenderListPageState();
}
class _SenderListPageState extends State<SenderListPage> {
final PagingController<int, SenderNameStatistics> _pagingController = PagingController.fromValue(PagingState(nextPageKey: null, itemList: [], error: null), firstPageKey: 0);
@override
void initState() {
super.initState();
_pagingController.addPageRequestListener(_fetchPage);
_pagingController.refresh();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
}
@override
void dispose() {
ApplicationLog.debug('SenderListPage::dispose');
_pagingController.dispose();
super.dispose();
}
Future<void> _fetchPage(int pageKey) async {
final acc = Provider.of<AppAuth>(context, listen: false);
ApplicationLog.debug('Start SenderListPage::_pagingController::_fetchPage [ ${pageKey} ]');
if (!acc.isAuth()) {
_pagingController.error = 'Not logged in';
return;
}
try {
final items = (await APIClient.getSenderNameList(acc)).toList();
items.sort((a, b) => -1 * a.lastTimestamp.compareTo(b.lastTimestamp));
_pagingController.value = PagingState(nextPageKey: null, itemList: items, error: null);
} catch (exc, trace) {
_pagingController.error = exc.toString();
ApplicationLog.error('Failed to list senders: ' + exc.toString(), trace: trace);
}
}
@override
Widget build(BuildContext context) {
return SCNScaffold(
title: "Sender",
showSearch: false,
showShare: false,
child: Padding(
padding: EdgeInsets.fromLTRB(8, 4, 8, 4),
child: RefreshIndicator(
onRefresh: () => Future.sync(
() => _pagingController.refresh(),
),
child: PagedListView<int, SenderNameStatistics>(
pagingController: _pagingController,
builderDelegate: PagedChildBuilderDelegate<SenderNameStatistics>(
itemBuilder: (context, item, index) => SenderListItem(item: item),
),
),
),
),
);
}
}

View File

@ -0,0 +1,87 @@
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:provider/provider.dart';
import 'package:simplecloudnotifier/api/api_client.dart';
import 'package:simplecloudnotifier/models/sender_name_statistics.dart';
import 'package:simplecloudnotifier/pages/filtered_message_view/filtered_message_view.dart';
import 'package:simplecloudnotifier/state/app_settings.dart';
import 'package:simplecloudnotifier/utils/navi.dart';
enum SenderListItemMode {
Messages,
Extended,
}
class SenderListItem extends StatelessWidget {
const SenderListItem({
required this.item,
super.key,
});
final SenderNameStatistics item;
@override
Widget build(BuildContext context) {
final dateFormat = context.select<AppSettings, AppSettingsDateFormat>((v) => v.dateFormat).dateOnlyFormat();
return Card.filled(
margin: EdgeInsets.fromLTRB(0, 4, 0, 4),
shape: BeveledRectangleBorder(borderRadius: BorderRadius.circular(0)),
color: Theme.of(context).cardTheme.color,
child: InkWell(
onTap: () {
Navi.push(context, () => FilteredMessageViewPage(title: item.name, filter: MessageFilter(senderNames: [item.name])));
},
child: Padding(
padding: const EdgeInsets.all(8),
child: Row(
children: [
Icon(FontAwesomeIcons.solidSignature, color: Theme.of(context).colorScheme.outline, size: 32),
SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: Text(
item.name,
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
],
),
SizedBox(height: 4),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Text(
dateFormat.format(DateTime.parse(item.lastTimestamp).toLocal()),
style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(160)),
),
),
Text(item.count.toString(), style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
],
),
],
),
),
SizedBox(width: 4),
GestureDetector(
onTap: () {
Navi.push(context, () => FilteredMessageViewPage(title: item.name, filter: MessageFilter(senderNames: [item.name])));
},
child: Padding(
padding: const EdgeInsets.all(8),
child: Icon(FontAwesomeIcons.solidEnvelopes, color: Theme.of(context).colorScheme.onPrimaryContainer.withAlpha(128), size: 24),
),
),
],
),
),
),
);
}
}

View File

@ -1,17 +0,0 @@
import 'package:flutter/material.dart';
class SettingsRootPage extends StatefulWidget {
const SettingsRootPage({super.key, required bool isVisiblePage});
@override
State<SettingsRootPage> createState() => _SettingsRootPageState();
}
class _SettingsRootPageState extends State<SettingsRootPage> {
@override
Widget build(BuildContext context) {
return Center(
child: Text('Settings'),
);
}
}

View File

@ -0,0 +1,117 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class SettingsNumberModal extends StatefulWidget {
final String title;
final int currentValue;
final int minValue;
final int maxValue;
final ValueChanged<int> onValueChanged;
const SettingsNumberModal({
Key? key,
required this.title,
required this.currentValue,
required this.minValue,
required this.maxValue,
required this.onValueChanged,
}) : super(key: key);
@override
State<SettingsNumberModal> createState() => _SettingsNumberModalState();
static Future<void> show(
BuildContext context, {
required String title,
required int currentValue,
required int minValue,
required int maxValue,
required ValueChanged<int> onValueChanged,
}) {
return showDialog(
context: context,
builder: (context) => SettingsNumberModal(
title: title,
currentValue: currentValue,
minValue: minValue,
maxValue: maxValue,
onValueChanged: onValueChanged,
),
);
}
}
class _SettingsNumberModalState extends State<SettingsNumberModal> {
late TextEditingController _controller;
late int selectedValue;
@override
void initState() {
super.initState();
selectedValue = widget.currentValue;
_controller = TextEditingController(text: widget.currentValue.toString());
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(widget.title),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: _controller,
keyboardType: TextInputType.number,
decoration: InputDecoration(
hintText: 'Enter a number',
errorText: _validateInput(),
),
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
onChanged: (value) {
setState(() {
selectedValue = int.tryParse(value) ?? widget.currentValue;
});
},
),
],
),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('Cancel'),
),
TextButton(
onPressed: _validateInput() == null
? () {
widget.onValueChanged(selectedValue);
Navigator.of(context).pop();
}
: null,
child: const Text('OK'),
),
],
);
}
String? _validateInput() {
final number = int.tryParse(_controller.text);
if (number == null) {
return 'Please enter a valid number';
}
if (number < widget.minValue) {
return 'Value must be at least ${widget.minValue}';
}
if (number > widget.maxValue) {
return 'Value must be at most ${widget.maxValue}';
}
return null;
}
}

View File

@ -0,0 +1,43 @@
import 'package:flutter/material.dart';
import 'package:settings_ui/settings_ui.dart';
class SettingsPickerScreen<T> extends StatelessWidget {
const SettingsPickerScreen({
Key? key,
required this.title,
required this.initialValue,
required this.values,
required this.onValueChanged,
this.icons,
}) : super(key: key);
final String title;
final T initialValue;
final List<T> values;
final void Function(T value) onValueChanged;
final Widget Function(T v)? icons;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(title)),
body: SettingsList(
platform: PlatformUtils.detectPlatform(context),
sections: [
SettingsSection(
tiles: values.map((e) {
return SettingsTile(
leading: icons != null ? icons!(e) : null,
title: Text(e.toString()),
onPressed: (_) {
onValueChanged(e);
Navigator.of(context).pop();
},
);
}).toList(),
),
],
),
);
}
}

View File

@ -0,0 +1,245 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:provider/provider.dart';
import 'package:settings_ui/settings_ui.dart';
import 'package:simplecloudnotifier/git_stamp/git_stamp.dart';
import 'package:simplecloudnotifier/pages/settings/settings_number_modal.dart';
import 'package:simplecloudnotifier/pages/settings/settings_picker_screen.dart';
import 'package:simplecloudnotifier/state/app_settings.dart';
import 'package:simplecloudnotifier/state/app_auth.dart';
import 'package:simplecloudnotifier/state/app_theme.dart';
import 'package:simplecloudnotifier/state/globals.dart';
import 'package:simplecloudnotifier/utils/navi.dart';
import 'package:simplecloudnotifier/utils/toaster.dart';
class SettingsRootPage extends StatefulWidget {
const SettingsRootPage({super.key, required bool isVisiblePage});
@override
State<SettingsRootPage> createState() => _SettingsRootPageState();
}
class _SettingsRootPageState extends State<SettingsRootPage> {
int _multiClickCounter = 0;
DateTime? _lastClickTime = null;
@override
Widget build(BuildContext context) {
final cfg = Provider.of<AppSettings>(context);
final thm = Provider.of<AppTheme>(context);
return SettingsList(
platform: PlatformUtils.detectPlatform(context),
contentPadding: EdgeInsets.fromLTRB(0, 0, 0, 24),
sections: [
SettingsSection(
title: Text('General'),
tiles: [
SettingsTile.navigation(
leading: Icon(thm.darkMode ? FontAwesomeIcons.solidMoon : FontAwesomeIcons.solidSun),
title: Text('Theme'),
value: Text(thm.darkMode ? 'Dark' : 'Light'),
onPressed: (_) => thm.switchDarkMode(),
),
SettingsTile.navigation(
leading: Icon(FontAwesomeIcons.solidSquare, color: thm.color.value),
title: Text('Color'),
value: Text(thm.color.displayStr),
onPressed: (_) => Navi.push(
context,
() => SettingsPickerScreen(
title: 'Color',
initialValue: thm.color,
values: ThemeColor.values,
icons: (v) => Icon(FontAwesomeIcons.solidSquare, color: v.value),
onValueChanged: (value) => AppTheme().setColor(value),
),
),
),
SettingsTile.navigation(
leading: Icon(FontAwesomeIcons.solidLineColumns),
title: Text('Message Preview Lines'),
value: Text("${cfg.messagePreviewLength}"),
onPressed: (_) {
SettingsNumberModal.show(
context,
title: 'Message Preview Lines',
currentValue: cfg.messagePreviewLength,
minValue: 1,
maxValue: 32,
onValueChanged: (value) => AppSettings().update((p) => p.messagePreviewLength = value),
);
},
),
if (Platform.isAndroid)
SettingsTile.switchTile(
initialValue: cfg.groupNotifications,
leading: Icon(FontAwesomeIcons.solidLayerGroup),
title: Text('Group notifications together'),
onToggle: (value) => AppSettings().update((p) => p.groupNotifications = !p.groupNotifications),
),
SettingsTile.navigation(
leading: Icon(FontAwesomeIcons.solidCalendarDays),
title: Text('Date Format'),
value: Text(cfg.dateFormat.displayStr),
onPressed: (_) => Navi.push(
context,
() => SettingsPickerScreen(
title: 'Date Format',
initialValue: cfg.dateFormat,
values: AppSettingsDateFormat.values,
onValueChanged: (value) => AppSettings().update((p) => p.dateFormat = value),
),
),
),
],
),
SettingsSection(
title: Text('Priority 0 (Low)'),
tiles: _buildNotificationTiles(context, cfg, 0),
),
SettingsSection(
title: Text('Priority 1 (Normal)'),
tiles: _buildNotificationTiles(context, cfg, 1),
),
SettingsSection(
title: Text('Priority 2 (High)'),
tiles: _buildNotificationTiles(context, cfg, 2),
),
SettingsSection(
title: Text('Advanced Settings'),
tiles: [
if (cfg.devMode)
SettingsTile.switchTile(
initialValue: cfg.showDebugButton,
leading: Icon(FontAwesomeIcons.solidSpiderBlackWidow),
title: Text('Debug Button anzeigen'),
onToggle: (value) => AppSettings().update((p) => p.showDebugButton = !p.showDebugButton),
),
SettingsTile.navigation(
leading: Icon(FontAwesomeIcons.solidList),
title: Text('Page Size (Messages)'),
value: Text("${cfg.messagePageSize}"),
onPressed: (_) {
SettingsNumberModal.show(
context,
title: 'Page Size (Messages)',
currentValue: cfg.messagePageSize,
minValue: 1,
maxValue: 2048,
onValueChanged: (value) => AppSettings().update((p) => p.messagePageSize = value),
);
},
),
SettingsTile.switchTile(
initialValue: cfg.backgroundRefreshMessageListOnPop,
leading: Icon(FontAwesomeIcons.solidPageCaretDown),
title: Text('Refresh messages on page navigation'),
onToggle: (value) => AppSettings().update((p) => p.backgroundRefreshMessageListOnPop = !p.backgroundRefreshMessageListOnPop),
),
SettingsTile.switchTile(
initialValue: cfg.alwaysBackgroundRefreshMessageListOnLifecycleResume,
leading: Icon(FontAwesomeIcons.solidRecycle),
title: Text('Refresh messages on app resume'),
onToggle: (value) => AppSettings().update((p) => p.alwaysBackgroundRefreshMessageListOnLifecycleResume = !p.alwaysBackgroundRefreshMessageListOnLifecycleResume),
),
],
),
SettingsSection(
title: Text('About'),
tiles: [
SettingsTile.navigation(
leading: Icon(FontAwesomeIcons.solidCodeCommit),
title: Text('Version'),
value: Text(Globals().version),
onPressed: (cfg.devMode)
? null
: (context) {
if (_lastClickTime == null || DateTime.now().difference(_lastClickTime!).inSeconds > 1) _multiClickCounter = 0;
_multiClickCounter++;
_lastClickTime = DateTime.now();
if (_multiClickCounter >= 12) {
Toaster.info("Debug", "Developer mode enabled");
AppSettings().update((p) {
p.devMode = true;
p.showDebugButton = true;
});
}
},
),
SettingsTile.navigation(
leading: Icon(FontAwesomeIcons.solidCodeBranch),
title: Text('Build'),
value: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(GitStamp.sha.substring(0, 7) + ' +' + Globals().buildNumber),
Text("( " + cfg.dateFormat.dateFormat().format(DateTime.parse(GitStamp.buildDateTime).toLocal()) + " )", style: TextStyle(fontStyle: FontStyle.italic)),
],
),
onPressed: (context) => _clipboardCopy(GitStamp.sha),
),
SettingsTile.navigation(
leading: Icon(FontAwesomeIcons.solidBell),
title: Text('FCM Token'),
value: Text(AppAuth().getToken()),
onPressed: (context) => _clipboardCopy(AppAuth().getToken()),
),
],
),
],
);
}
void _clipboardCopy(String v) {
Clipboard.setData(new ClipboardData(text: v));
Toaster.info("Clipboard", 'Copied to Clipboard');
print('================= [CLIPBOARD] =================\n${v}\n================= [/CLIPBOARD] =================');
}
List<AbstractSettingsTile> _buildNotificationTiles(BuildContext context, AppSettings cfg, int prio) {
final ncf = AppSettings().getNotificationSettings(prio);
return [
SettingsTile.switchTile(
initialValue: ncf.enableLights,
leading: Icon(FontAwesomeIcons.solidLightbulb),
title: Text('Enable Lights'),
onToggle: (value) => AppSettings().updateNotification(prio, (p) => p.withEnableLights(!p.enableLights)),
),
SettingsTile.switchTile(
initialValue: ncf.enableVibration,
leading: Icon(FontAwesomeIcons.solidShutters),
title: Text('Enable Vibration'),
onToggle: (value) => AppSettings().updateNotification(prio, (p) => p.withEnableVibration(!p.enableVibration)),
),
SettingsTile.navigation(
leading: Icon(FontAwesomeIcons.solidWaveform),
title: Text('Notification Sound'),
value: Text(ncf.sound ?? '(Default)'),
onPressed: (context) => {/*TODO*/},
),
SettingsTile.switchTile(
initialValue: ncf.playSound,
leading: Icon(FontAwesomeIcons.solidVolume),
title: Text('Play Sound'),
onToggle: (value) => AppSettings().updateNotification(prio, (p) => p.withPlaySound(!p.playSound)),
),
SettingsTile.switchTile(
initialValue: ncf.silent,
leading: Icon(FontAwesomeIcons.solidVolumeSlash),
title: Text('Silent'),
onToggle: (value) => AppSettings().updateNotification(prio, (p) => p.withSilent(!p.silent)),
),
SettingsTile.navigation(
leading: Icon(FontAwesomeIcons.solidStopwatch20),
title: Text('Auto Timeout'),
value: Text((ncf.timeoutAfter != null) ? "${ncf.timeoutAfter} sec" : "(None)"),
onPressed: (context) => {/*TODO*/},
),
];
}
}

View File

@ -0,0 +1,115 @@
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/models/subscription.dart';
import 'package:simplecloudnotifier/models/user.dart';
import 'package:simplecloudnotifier/state/application_log.dart';
import 'package:simplecloudnotifier/state/app_auth.dart';
import 'package:simplecloudnotifier/pages/subscription_list/subscription_list_item.dart';
import 'package:simplecloudnotifier/state/scn_data_cache.dart';
class SubscriptionListPage extends StatefulWidget {
const SubscriptionListPage({super.key});
@override
State<SubscriptionListPage> createState() => _SubscriptionListPageState();
}
class _SubscriptionListPageState extends State<SubscriptionListPage> {
final PagingController<int, Subscription> _pagingController = PagingController.fromValue(PagingState(nextPageKey: null, itemList: [], error: null), firstPageKey: 0);
final userCache = Map<String, UserPreview>();
final channelCache = Map<String, ChannelPreview>();
@override
void initState() {
super.initState();
for (var v in SCNDataCache().getChannelMap().entries) channelCache[v.key] = v.value.toPreview(null);
_pagingController.addPageRequestListener(_fetchPage);
_pagingController.refresh();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
}
@override
void dispose() {
ApplicationLog.debug('SubscriptionListPage::dispose');
_pagingController.dispose();
super.dispose();
}
Future<void> _fetchPage(int pageKey) async {
final acc = Provider.of<AppAuth>(context, listen: false);
ApplicationLog.debug('Start SubscriptionListPage::_pagingController::_fetchPage [ ${pageKey} ]');
if (!acc.isAuth()) {
_pagingController.error = 'Not logged in';
return;
}
try {
final items = (await APIClient.getSubscriptionList(acc)).toList();
items.sort((a, b) => -1 * a.timestampCreated.compareTo(b.timestampCreated));
var promises = Map<String, Future<UserPreview>>();
for (var item in items) {
if (userCache[item.subscriberUserID] == null && !promises.containsKey(item.subscriberUserID)) {
promises[item.subscriberUserID] = APIClient.getUserPreview(acc, item.subscriberUserID).then((p) => userCache[p.userID] = p);
}
if (userCache[item.channelOwnerUserID] == null && !promises.containsKey(item.channelOwnerUserID)) {
promises[item.channelOwnerUserID] = APIClient.getUserPreview(acc, item.channelOwnerUserID).then((p) => userCache[p.userID] = p);
}
if (channelCache[item.channelID] == null && !promises.containsKey(item.channelID)) {
channelCache[item.channelID] = await APIClient.getChannelPreview(acc, item.channelID).then((p) => channelCache[p.channelID] = p);
}
}
await Future.wait(promises.values);
_pagingController.value = PagingState(nextPageKey: null, itemList: items, error: null);
} catch (exc, trace) {
_pagingController.error = exc.toString();
ApplicationLog.error('Failed to list subscriptions: ' + exc.toString(), trace: trace);
}
}
@override
Widget build(BuildContext context) {
return SCNScaffold(
title: "Subscriptions",
showSearch: false,
showShare: false,
child: Padding(
padding: EdgeInsets.fromLTRB(8, 4, 8, 4),
child: RefreshIndicator(
onRefresh: () => Future.sync(
() => _pagingController.refresh(),
),
child: PagedListView<int, Subscription>(
pagingController: _pagingController,
builderDelegate: PagedChildBuilderDelegate<Subscription>(
itemBuilder: (context, item, index) => SubscriptionListItem(item: item, userCache: userCache, channelCache: channelCache, needsReload: fullRefresh),
),
),
),
),
);
}
void fullRefresh() {
ApplicationLog.debug('SubscriptionListPage::fullRefresh');
_pagingController.refresh();
}
}

View File

@ -0,0 +1,113 @@
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:provider/provider.dart';
import 'package:simplecloudnotifier/models/channel.dart';
import 'package:simplecloudnotifier/models/subscription.dart';
import 'package:simplecloudnotifier/models/user.dart';
import 'package:simplecloudnotifier/pages/subscription_view/subscription_view.dart';
import 'package:simplecloudnotifier/state/app_auth.dart';
import 'package:simplecloudnotifier/utils/navi.dart';
enum SubscriptionListItemMode {
Messages,
Extended,
}
class SubscriptionListItem extends StatelessWidget {
const SubscriptionListItem({
required this.item,
required this.userCache,
required this.channelCache,
required this.needsReload,
super.key,
});
final Subscription item;
final Map<String, UserPreview> userCache;
final Map<String, ChannelPreview> channelCache;
final void Function()? needsReload;
@override
Widget build(BuildContext context) {
final channelOwner = userCache[item.channelOwnerUserID];
final subscriber = userCache[item.subscriberUserID];
final channel = channelCache[item.channelID];
return Card.filled(
margin: EdgeInsets.fromLTRB(0, 4, 0, 4),
shape: BeveledRectangleBorder(borderRadius: BorderRadius.circular(0)),
color: Theme.of(context).cardTheme.color,
child: InkWell(
onTap: () {
Navi.push(context, () => SubscriptionViewPage(subscriptionID: item.subscriptionID, preloadedData: (item, channelOwner, subscriber, channel), needsReload: this.needsReload));
},
child: Padding(
padding: const EdgeInsets.all(8),
child: Row(
children: [
Icon(FontAwesomeIcons.solidDiagramSubtask, color: Theme.of(context).colorScheme.outline, size: 32),
SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
subscriber?.username ?? item.subscriberUserID,
style: const TextStyle(),
),
SizedBox(height: 4),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
"@" + (channel?.displayName ?? item.channelID),
style: const TextStyle(fontWeight: FontWeight.bold),
),
SizedBox(width: 10),
Text(
"(" + (channelOwner?.username ?? item.channelOwnerUserID) + ")",
style: const TextStyle(fontStyle: FontStyle.italic),
),
],
),
],
),
),
SizedBox(width: 4),
Padding(
padding: const EdgeInsets.all(8),
child: _buildIcon(context),
),
],
),
),
),
);
}
Widget _buildIcon(BuildContext context) {
final acc = Provider.of<AppAuth>(context, listen: false);
final colorFull = Theme.of(context).colorScheme.onPrimaryContainer;
final colorHalf = Theme.of(context).colorScheme.onPrimaryContainer.withAlpha(75);
final isOwned = item.channelOwnerUserID == acc.userID && item.subscriberUserID == acc.userID;
final isIncoming = item.channelOwnerUserID == acc.userID && item.subscriberUserID != acc.userID;
final isOutgoing = item.channelOwnerUserID != acc.userID && item.subscriberUserID == acc.userID;
if (isOutgoing && !item.confirmed) return Icon(FontAwesomeIcons.solidSquareEnvelope, color: colorHalf, size: 24);
if (isOutgoing && !item.active) return Icon(FontAwesomeIcons.solidSquareShareNodes, color: colorHalf, size: 24);
if (isOutgoing && item.active) return Icon(FontAwesomeIcons.solidSquareShareNodes, color: colorFull, size: 24);
if (isIncoming && !item.confirmed) return Icon(FontAwesomeIcons.solidSquareQuestion, color: colorHalf, size: 24);
if (isIncoming && !item.active) return Icon(FontAwesomeIcons.solidSquareCheck, color: colorHalf, size: 24);
if (isIncoming && item.active) return Icon(FontAwesomeIcons.solidSquareCheck, color: colorFull, size: 24);
if (isOwned && !item.confirmed) return Icon(FontAwesomeIcons.solidSquare, color: colorHalf, size: 24); // should not be possible
if (isOwned && !item.active) return Icon(FontAwesomeIcons.solidSquareRss, color: colorHalf, size: 24);
if (isOwned && item.active) return Icon(FontAwesomeIcons.solidSquareRss, color: colorFull, size: 24);
return SizedBox(width: 24, height: 24); // should also not be possible
}
}

View File

@ -0,0 +1,474 @@
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:intl/intl.dart';
import 'package:simplecloudnotifier/api/api_client.dart';
import 'package:simplecloudnotifier/components/error_display/error_display.dart';
import 'package:simplecloudnotifier/components/layout/scaffold.dart';
import 'package:simplecloudnotifier/models/channel.dart';
import 'package:simplecloudnotifier/models/subscription.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/state/app_settings.dart';
import 'package:simplecloudnotifier/state/application_log.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';
import 'package:provider/provider.dart';
class SubscriptionViewPage extends StatefulWidget {
const SubscriptionViewPage({
required this.subscriptionID,
required this.preloadedData,
required this.needsReload,
super.key,
});
final String subscriptionID;
final (Subscription?, UserPreview?, UserPreview?, ChannelPreview?)? preloadedData;
final void Function()? needsReload;
@override
State<SubscriptionViewPage> createState() => _SubscriptionViewPageState();
}
enum EditState { none, editing, saving }
enum SubscriptionViewPageInitState { loading, okay, error }
class _SubscriptionViewPageState extends State<SubscriptionViewPage> {
ImmediateFuture<UserPreview> _futureChannelOwner = ImmediateFuture.ofPending();
ImmediateFuture<UserPreview> _futureSubscriber = ImmediateFuture.ofPending();
ImmediateFuture<ChannelPreview> _futureChannel = ImmediateFuture.ofPending();
int _loadingIndeterminateCounter = 0;
Subscription? subscription;
SubscriptionViewPageInitState loadingState = SubscriptionViewPageInitState.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?.$1 != null && widget.preloadedData!.$1!.subscriptionID == widget.subscriptionID && usePreload) {
subscription = widget.preloadedData!.$1!;
} else {
try {
var r = await APIClient.getSubscription(userAcc, widget.subscriptionID);
setState(() {
subscription = r;
});
} 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 = SubscriptionViewPageInitState.error;
return;
}
}
setState(() {
this.loadingState = SubscriptionViewPageInitState.okay;
assert(subscription != null);
if (widget.preloadedData?.$2 != null && widget.preloadedData!.$2!.userID == this.subscription!.channelOwnerUserID && usePreload) {
_futureChannelOwner = ImmediateFuture<UserPreview>.ofValue(widget.preloadedData!.$2!);
} else if (widget.preloadedData?.$3 != null && widget.preloadedData!.$3!.userID == this.subscription!.channelOwnerUserID && usePreload) {
_futureChannelOwner = ImmediateFuture<UserPreview>.ofValue(widget.preloadedData!.$3!);
} else if (this.subscription!.channelOwnerUserID == userAcc.userID) {
var cacheUser = userAcc.getUserOrNull();
if (cacheUser != null) {
_futureChannelOwner = ImmediateFuture<UserPreview>.ofValue(cacheUser.toPreview());
} else {
_futureChannelOwner = ImmediateFuture<UserPreview>.ofFuture(_getUserPreview(userAcc, this.subscription!.channelOwnerUserID));
}
} else {
_futureChannelOwner = ImmediateFuture<UserPreview>.ofFuture(APIClient.getUserPreview(userAcc, this.subscription!.channelOwnerUserID));
}
if (widget.preloadedData?.$2 != null && widget.preloadedData!.$2!.userID == this.subscription!.subscriberUserID && usePreload) {
_futureSubscriber = ImmediateFuture<UserPreview>.ofValue(widget.preloadedData!.$2!);
} else if (widget.preloadedData?.$3 != null && widget.preloadedData!.$3!.userID == this.subscription!.subscriberUserID && usePreload) {
_futureSubscriber = ImmediateFuture<UserPreview>.ofValue(widget.preloadedData!.$3!);
} else if (this.subscription!.subscriberUserID == userAcc.userID) {
var cacheUser = userAcc.getUserOrNull();
if (cacheUser != null) {
_futureSubscriber = ImmediateFuture<UserPreview>.ofValue(cacheUser.toPreview());
} else {
_futureSubscriber = ImmediateFuture<UserPreview>.ofFuture(_getUserPreview(userAcc, this.subscription!.subscriberUserID));
}
} else {
_futureSubscriber = ImmediateFuture<UserPreview>.ofFuture(APIClient.getUserPreview(userAcc, this.subscription!.subscriberUserID));
}
if (widget.preloadedData?.$4 != null && widget.preloadedData!.$4!.channelID == this.subscription!.channelID && usePreload) {
_futureChannel = ImmediateFuture<ChannelPreview>.ofValue(widget.preloadedData!.$4!);
} else {
_futureChannel = ImmediateFuture<ChannelPreview>.ofFuture(APIClient.getChannelPreview(userAcc, this.subscription!.channelID));
}
});
}
@override
void dispose() {
super.dispose();
}
@override
Widget build(BuildContext context) {
final userAcc = Provider.of<AppAuth>(context, listen: false);
Widget child;
if (loadingState == SubscriptionViewPageInitState.loading) {
child = Center(child: CircularProgressIndicator());
} else if (loadingState == SubscriptionViewPageInitState.error) {
child = ErrorDisplay(errorMessage: errorMessage);
} else if (loadingState == SubscriptionViewPageInitState.okay) {
if (subscription!.channelOwnerUserID == userAcc.userID && subscription!.subscriberUserID == userAcc.userID) {
child = _buildOwnedSubscriptionView(context, this.subscription!);
} else if (subscription!.channelOwnerUserID == userAcc.userID) {
child = _buildIncomingSubscriptionView(context, this.subscription!);
} else if (subscription!.subscriberUserID == userAcc.userID) {
child = _buildOutgoingSubscriptionView(context, this.subscription!);
} else {
child = ErrorDisplay(errorMessage: 'Invalid subscription state!');
}
} else {
child = ErrorDisplay(errorMessage: 'Invalid page state!');
}
return SCNScaffold(
title: "Subscription",
showSearch: false,
showShare: false,
child: child,
);
}
Widget _buildOwnedSubscriptionView(BuildContext context, Subscription subscription) {
final dateFormat = context.select<AppSettings, AppSettingsDateFormat>((v) => v.dateFormat).dateOnlyFormat();
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: 'SubscriptionID',
values: [subscription.subscriptionID],
),
_buildChannelOwnerCard(context, subscription),
_buildSubscriberCard(context, subscription),
_buildChannelCard(context, subscription),
UI.metaCard(
context: context,
icon: FontAwesomeIcons.clock,
title: 'Created',
values: [dateFormat.format(DateTime.parse(subscription.timestampCreated).toLocal())],
),
_buildStatusCard(context),
UI.button(text: "Unsubscribe", onPressed: _unsubscribe, tonal: true),
],
),
),
);
}
Widget _buildIncomingSubscriptionView(BuildContext context, Subscription subscription) {
final dateFormat = context.select<AppSettings, AppSettingsDateFormat>((v) => v.dateFormat).dateOnlyFormat();
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: 'SubscriptionID',
values: [subscription.subscriptionID],
),
_buildChannelOwnerCard(context, subscription),
_buildSubscriberCard(context, subscription),
_buildChannelCard(context, subscription),
UI.metaCard(
context: context,
icon: FontAwesomeIcons.clock,
title: 'Created',
values: [dateFormat.format(DateTime.parse(subscription.timestampCreated).toLocal())],
),
_buildStatusCard(context),
if (subscription.confirmed) UI.button(text: "Revoke subscription", onPressed: _unsubscribe, color: Colors.red),
if (!subscription.confirmed) UI.button(text: "Confirm subscription", onPressed: _confirm, color: Colors.green),
if (!subscription.confirmed) UI.button(text: "Deny subscription", onPressed: _unsubscribe, color: Colors.red),
],
),
),
);
}
Widget _buildOutgoingSubscriptionView(BuildContext context, Subscription subscription) {
final dateFormat = context.select<AppSettings, AppSettingsDateFormat>((v) => v.dateFormat).dateOnlyFormat();
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: 'SubscriptionID',
values: [subscription.subscriptionID],
),
_buildChannelOwnerCard(context, subscription),
_buildSubscriberCard(context, subscription),
_buildChannelCard(context, subscription),
UI.metaCard(
context: context,
icon: FontAwesomeIcons.clock,
title: 'Created',
values: [dateFormat.format(DateTime.parse(subscription.timestampCreated).toLocal())],
),
_buildStatusCard(context),
if (subscription.confirmed && subscription.active) UI.button(text: "Deactivate subscription", onPressed: _deactivate, tonal: true),
if (subscription.confirmed && !subscription.active) UI.button(text: "Activate subscription", onPressed: _activate, tonal: true),
if (subscription.confirmed && !subscription.active) UI.button(text: "Delete subscription", onPressed: () => _unsubscribe(confirm: 'Really (permanently) delete the subscription to this channel?'), color: Colors.red),
if (!subscription.confirmed) UI.button(text: "Cancel subscription request", onPressed: _unsubscribe, tonal: true),
],
),
),
);
}
Widget _buildChannelOwnerCard(BuildContext context, Subscription subscription) {
final userAcc = Provider.of<AppAuth>(context, listen: false);
bool isSelf = subscription.channelOwnerUserID == userAcc.userID;
return FutureBuilder(
future: _futureChannelOwner.future,
builder: (context, snapshot) {
if (snapshot.hasData) {
return UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidUser,
title: 'Channel Owner',
values: [subscription.channelOwnerUserID + (isSelf ? ' (you)' : ''), if (snapshot.data?.username != null) snapshot.data!.username!],
);
} else {
return UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidUser,
title: 'Channel Owner',
values: [subscription.channelOwnerUserID + (isSelf ? ' (you)' : '')],
);
}
},
);
}
Widget _buildSubscriberCard(BuildContext context, Subscription subscription) {
final userAcc = Provider.of<AppAuth>(context, listen: false);
bool isSelf = subscription.subscriberUserID == userAcc.userID;
return FutureBuilder(
future: _futureSubscriber.future,
builder: (context, snapshot) {
if (snapshot.hasData) {
return UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidUser,
title: 'Subscriber',
values: [subscription.subscriberUserID + (isSelf ? ' (you)' : ''), if (snapshot.data?.username != null) snapshot.data!.username!],
);
} else {
return UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidUser,
title: 'Subscriber',
values: [subscription.subscriberUserID + (isSelf ? ' (you)' : '')],
);
}
},
);
}
Widget _buildChannelCard(BuildContext context, Subscription subscription) {
return FutureBuilder(
future: _futureChannel.future,
builder: (context, snapshot) {
if (snapshot.hasData) {
return UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidSnake,
title: 'Channel',
values: [subscription.channelID, snapshot.data!.displayName],
mainAction: () => Navi.push(context, () => ChannelViewPage(channelID: subscription.channelID, preloadedData: null, needsReload: null)),
);
} else {
return UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidSnake,
title: 'Channel',
values: [subscription.channelID, subscription.channelInternalName],
mainAction: () => Navi.push(context, () => ChannelViewPage(channelID: subscription.channelID, preloadedData: null, needsReload: null)),
);
}
},
);
}
Widget _buildStatusCard(BuildContext context) {
final acc = Provider.of<AppAuth>(context, listen: false);
final item = subscription!;
final isOwned = item.channelOwnerUserID == acc.userID && item.subscriberUserID == acc.userID;
final isIncoming = item.channelOwnerUserID == acc.userID && item.subscriberUserID != acc.userID;
final isOutgoing = item.channelOwnerUserID != acc.userID && item.subscriberUserID == acc.userID;
var status = ['ERROR?'];
if (isOutgoing && !item.confirmed) status = ['Subscription to foreign channel', 'Pending confirmation'];
if (isOutgoing && !item.active) status = ['Subscription to foreign channel', 'Confirmed but inactive'];
if (isOutgoing && item.active) status = ['Subscription to foreign channel', 'Confirmed and active'];
if (isIncoming && !item.confirmed) status = ['External subscription to your channel', 'Pending confirmation'];
if (isIncoming && !item.active) status = ['External subscription to your channel', 'Deactivated by subscriber'];
if (isIncoming && item.active) status = ['External subscription to your channel', 'Confirmed and active'];
if (isOwned && !item.confirmed) status = ['Your own channel', 'ERROR'];
if (isOwned && !item.active) status = ['Your own channel', 'Not subscribed'];
if (isOwned && item.active) status = ['Your own channel', 'Active subscription'];
return UI.metaCard(
context: context,
icon: FontAwesomeIcons.solidInfo,
title: 'Status',
values: status,
);
}
Future<UserPreview> _getUserPreview(AppAuth auth, String uid) 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, uid);
//await Future.delayed(const Duration(seconds: 10), () {});
return owner;
} finally {
_incLoadingIndeterminateCounter(-1);
}
}
void _incLoadingIndeterminateCounter(int delta) {
setState(() {
_loadingIndeterminateCounter += delta;
AppBarState().setLoadingIndeterminate(_loadingIndeterminateCounter > 0);
});
}
void _confirm() async {
final acc = AppAuth();
if (subscription == null) return;
try {
await APIClient.confirmSubscription(acc, subscription!.channelID, subscription!.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 _unsubscribe({String? confirm = null}) async {
final acc = AppAuth();
if (subscription == null) return;
if (confirm != null) {
final r = await UIDialogs.showConfirmDialog(context, confirm, okText: 'Unsubscribe', cancelText: 'Cancel');
if (!r) return;
}
try {
await APIClient.deleteSubscription(acc, subscription!.channelID, subscription!.subscriptionID);
widget.needsReload?.call();
Toaster.success("Success", 'Unsubscribed from channel');
Navi.pop(context);
} catch (exc, trace) {
Toaster.error("Error", 'Failed to unsubscribe from channel');
ApplicationLog.error('Failed to unsubscribe from channel: ' + exc.toString(), trace: trace);
}
}
void _deactivate() async {
final acc = AppAuth();
if (subscription == null) return;
try {
await APIClient.deactivateSubscription(acc, subscription!.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 _activate() async {
final acc = AppAuth();
if (subscription == null) return;
try {
await APIClient.activateSubscription(acc, subscription!.channelID, subscription!.subscriptionID);
widget.needsReload?.call();
await _initStateAsync(false);
Toaster.success("Success", 'Subscribed to channel');
} catch (exc, trace) {
Toaster.error("Error", 'Failed to subscribe to channel');
ApplicationLog.error('Failed to subscribe to channel: ' + exc.toString(), trace: trace);
}
}
}

View File

@ -1,35 +0,0 @@
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

@ -34,7 +34,7 @@ class AppAuth extends ChangeNotifier implements TokenSource {
} }
bool isAuth() { bool isAuth() {
return _userID != null && _tokenAdmin != null; return _userID != null && _tokenAdmin != null && _tokenSend != null;
} }
void set(User user, Client client, String tokenAdmin, String tokenSend) { void set(User user, Client client, String tokenAdmin, String tokenSend) {
@ -229,4 +229,8 @@ class AppAuth extends ChangeNotifier implements TokenSource {
String getUserID() { String getUserID() {
return _userID!; return _userID!;
} }
String? getClientID() {
return _clientID;
}
} }

View File

@ -0,0 +1,223 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:intl/intl.dart';
import 'package:shared_preferences/src/shared_preferences_legacy.dart';
import 'package:simplecloudnotifier/state/globals.dart';
enum AppSettingsDateFormat {
ISO(displayStr: 'ISO (yyyy-MM-dd)', key: 'ISO'),
German(displayStr: 'German (dd.MM.yyyy)', key: 'German'),
US(displayStr: 'US (MM/dd/yyyy)', key: 'US');
const AppSettingsDateFormat({required this.displayStr, required this.key});
final String displayStr;
final String key;
@override
toString() => displayStr;
DateFormat dateFormat() {
switch (this) {
case AppSettingsDateFormat.ISO:
return DateFormat('yyyy-MM-dd HH:mm');
case AppSettingsDateFormat.German:
return DateFormat('dd.MM.yyyy HH:mm');
case AppSettingsDateFormat.US:
return DateFormat('MM/dd/yyyy HH:mm');
}
}
DateFormat dateOnlyFormat() {
switch (this) {
case AppSettingsDateFormat.ISO:
return DateFormat('yyyy-MM-dd');
case AppSettingsDateFormat.German:
return DateFormat('dd.MM.yyyy');
case AppSettingsDateFormat.US:
return DateFormat('MM/dd/yyyy');
}
}
static AppSettingsDateFormat? parse(String? string) {
if (string == null) return null;
return values.firstWhere((e) => e.key == string, orElse: null);
}
}
class AppSettings extends ChangeNotifier {
bool groupNotifications = true;
int messagePageSize = 128;
bool devMode = false;
bool showDebugButton = false;
bool backgroundRefreshMessageListOnPop = false;
bool alwaysBackgroundRefreshMessageListOnLifecycleResume = true;
AppSettingsDateFormat dateFormat = AppSettingsDateFormat.ISO;
int messagePreviewLength = 3;
AppNotificationSettings notification0 = AppNotificationSettings();
AppNotificationSettings notification1 = AppNotificationSettings();
AppNotificationSettings notification2 = AppNotificationSettings();
static AppSettings? _singleton = AppSettings._internal();
factory AppSettings() {
return _singleton ?? (_singleton = AppSettings._internal());
}
AppSettings._internal() {
load();
}
void reset() {
groupNotifications = true;
messagePageSize = 128;
devMode = false;
showDebugButton = false;
backgroundRefreshMessageListOnPop = false;
alwaysBackgroundRefreshMessageListOnLifecycleResume = true;
dateFormat = AppSettingsDateFormat.ISO;
messagePreviewLength = 3;
notification0 = AppNotificationSettings();
notification1 = AppNotificationSettings();
notification2 = AppNotificationSettings();
notifyListeners();
}
void load() {
groupNotifications = Globals().sharedPrefs.getBool('settings.groupNotifications') ?? groupNotifications;
messagePageSize = Globals().sharedPrefs.getInt('settings.messagePageSize') ?? messagePageSize;
devMode = Globals().sharedPrefs.getBool('settings.devMode') ?? devMode;
showDebugButton = Globals().sharedPrefs.getBool('settings.showDebugButton') ?? showDebugButton;
backgroundRefreshMessageListOnPop = Globals().sharedPrefs.getBool('settings.backgroundRefreshMessageListOnPop') ?? backgroundRefreshMessageListOnPop;
alwaysBackgroundRefreshMessageListOnLifecycleResume = Globals().sharedPrefs.getBool('settings.alwaysBackgroundRefreshMessageListOnLifecycleResume') ?? alwaysBackgroundRefreshMessageListOnLifecycleResume;
dateFormat = AppSettingsDateFormat.parse(Globals().sharedPrefs.getString('settings.dateFormat')) ?? dateFormat;
messagePreviewLength = Globals().sharedPrefs.getInt('settings.messagePreviewLength') ?? messagePreviewLength;
notification0 = AppNotificationSettings.load(Globals().sharedPrefs, 'settings.notification0');
notification1 = AppNotificationSettings.load(Globals().sharedPrefs, 'settings.notification1');
notification2 = AppNotificationSettings.load(Globals().sharedPrefs, 'settings.notification2');
}
Future<void> save() async {
await Globals().sharedPrefs.setBool('settings.groupNotifications', groupNotifications);
await Globals().sharedPrefs.setInt('settings.messagePageSize', messagePageSize);
await Globals().sharedPrefs.setBool('settings.devMode', devMode);
await Globals().sharedPrefs.setBool('settings.showDebugButton', showDebugButton);
await Globals().sharedPrefs.setBool('settings.backgroundRefreshMessageListOnPop', backgroundRefreshMessageListOnPop);
await Globals().sharedPrefs.setBool('settings.alwaysBackgroundRefreshMessageListOnLifecycleResume', alwaysBackgroundRefreshMessageListOnLifecycleResume);
await Globals().sharedPrefs.setString('settings.dateFormat', dateFormat.key);
await Globals().sharedPrefs.setInt('settings.messagePreviewLength', messagePreviewLength);
await notification0.save(Globals().sharedPrefs, 'settings.notification0');
await notification1.save(Globals().sharedPrefs, 'settings.notification1');
await notification2.save(Globals().sharedPrefs, 'settings.notification2');
}
void update(void Function(AppSettings p) fn) {
fn(this);
save();
notifyListeners();
}
void updateNotification(int prio, AppNotificationSettings Function(AppNotificationSettings p) fn) {
if (prio == 0) {
notification0 = fn(notification0);
} else if (prio == 1) {
notification1 = fn(notification1);
} else if (prio == 2) {
notification2 = fn(notification2);
}
save();
notifyListeners();
}
AppNotificationSettings getNotificationSettings(int? prio) {
if (prio != null && prio == 0) {
return notification0;
} else if (prio != null && prio == 1) {
return notification1;
} else if (prio != null && prio == 2) {
return notification2;
} else {
return AppNotificationSettings();
}
}
}
class AppNotificationSettings {
// Immutable
AppNotificationSettings({
this.enableLights = false,
this.enableVibration = true,
this.playSound = true,
this.sound = null,
this.silent = false,
this.timeoutAfter = null,
});
final bool enableLights;
final bool enableVibration;
final bool playSound;
final String? sound;
final bool silent;
final int? timeoutAfter;
Future<void> save(SharedPreferences sharedPrefs, String prefix) async {
await Globals().sharedPrefs.setBool('${prefix}.enableLights', enableLights);
await Globals().sharedPrefs.setBool('${prefix}.enableVibration', enableVibration);
await Globals().sharedPrefs.setBool('${prefix}.playSound', playSound);
await Globals().sharedPrefs.setString('${prefix}.sound', _encode(sound));
await Globals().sharedPrefs.setBool('${prefix}.silent', silent);
await Globals().sharedPrefs.setString('${prefix}.timeoutAfter', _encode(timeoutAfter));
}
UriAndroidNotificationSound? soundURI() {
return (sound != null) ? UriAndroidNotificationSound(sound!) : null;
}
AppNotificationSettings withEnableLights(bool v) => AppNotificationSettings(enableLights: v, enableVibration: enableVibration, playSound: playSound, sound: sound, silent: silent, timeoutAfter: timeoutAfter);
AppNotificationSettings withEnableVibration(bool v) => AppNotificationSettings(enableLights: enableLights, enableVibration: v, playSound: playSound, sound: sound, silent: silent, timeoutAfter: timeoutAfter);
AppNotificationSettings withPlaySound(bool v) => AppNotificationSettings(enableLights: enableLights, enableVibration: enableVibration, playSound: v, sound: sound, silent: silent, timeoutAfter: timeoutAfter);
AppNotificationSettings withSound(String? v) => AppNotificationSettings(enableLights: enableLights, enableVibration: enableVibration, playSound: playSound, sound: v, silent: silent, timeoutAfter: timeoutAfter);
AppNotificationSettings withSilent(bool v) => AppNotificationSettings(enableLights: enableLights, enableVibration: enableVibration, playSound: playSound, sound: sound, silent: v, timeoutAfter: timeoutAfter);
AppNotificationSettings withTimeoutAfter(int? v) => AppNotificationSettings(enableLights: enableLights, enableVibration: enableVibration, playSound: playSound, sound: sound, silent: silent, timeoutAfter: v);
static AppNotificationSettings load(SharedPreferences prefs, String prefix) {
final def = AppNotificationSettings();
final enableLights = prefs.getBool('${prefix}.enableLights') ?? def.enableLights;
final enableVibration = prefs.getBool('${prefix}.enableVibration') ?? def.enableVibration;
final playSound = prefs.getBool('${prefix}.playSound') ?? def.playSound;
final sound = _decode(prefs.getString('${prefix}.sound'), def.sound);
final silent = prefs.getBool('${prefix}.silent') ?? def.silent;
final timeoutAfter = _decode(prefs.getString('${prefix}.timeoutAfter'), def.timeoutAfter);
return AppNotificationSettings(
enableLights: enableLights,
enableVibration: enableVibration,
playSound: playSound,
sound: sound,
silent: silent,
timeoutAfter: timeoutAfter,
);
}
}
String _encode<T>(T v) {
return JsonEncoder().convert(v);
}
T _decode<T>(String? v, T fallback) {
if (v == null) return fallback;
try {
return JsonDecoder().convert(v) as T;
} catch (_) {
return fallback;
}
}

View File

@ -1,16 +1,87 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:simplecloudnotifier/state/globals.dart';
enum ThemeColor {
Pink(displayStr: 'Pink', key: 'PINK', value: Colors.pink),
Red(displayStr: 'Red', key: 'RED', value: Colors.red),
DeepOrange(displayStr: 'Deep-Orange', key: 'DEEPORANGE', value: Colors.deepOrange),
Orange(displayStr: 'Orange', key: 'ORANGE', value: Colors.orange),
Amber(displayStr: 'Amber', key: 'AMBER', value: Colors.amber),
Yellow(displayStr: 'Yellow', key: 'YELLOW', value: Colors.yellow),
Lime(displayStr: 'Lime', key: 'LIME', value: Colors.lime),
LightGreen(displayStr: 'Light-Green', key: 'LIGHTGREEN', value: Colors.lightGreen),
Green(displayStr: 'Green', key: 'GREEN', value: Colors.green),
Teal(displayStr: 'Teal', key: 'TEAL', value: Colors.teal),
Cyan(displayStr: 'Cyan', key: 'CYAN', value: Colors.cyan),
LightBlue(displayStr: 'Light-Blue', key: 'LIGHTBLUE', value: Colors.lightBlue),
Blue(displayStr: 'Blue', key: 'BLUE', value: Colors.blue),
Indigo(displayStr: 'Indigo', key: 'INDIGO', value: Colors.indigo),
Purple(displayStr: 'Purple', key: 'PURPLE', value: Colors.purple),
DeepPurple(displayStr: 'Deep-Purple', key: 'DEEPPURPLE', value: Colors.deepPurple),
BlueGrey(displayStr: 'Blue-Grey', key: 'BLUEGREY', value: Colors.blueGrey),
Brown(displayStr: 'Brown', key: 'BROWN', value: Colors.brown),
Grey(displayStr: 'Grey', key: 'GREY', value: Colors.grey);
const ThemeColor({required this.displayStr, required this.key, required this.value});
final String displayStr;
final String key;
final Color value;
@override
toString() => displayStr;
static ThemeColor? parse(String? string) {
if (string == null) return null;
return values.firstWhere((e) => e.key == string, orElse: null);
}
}
class AppTheme extends ChangeNotifier { class AppTheme extends ChangeNotifier {
static AppTheme? _singleton = AppTheme._internal();
factory AppTheme() {
return _singleton ?? (_singleton = AppTheme._internal());
}
AppTheme._internal() {}
// --------------------------------------------------------------------------
bool _darkmode = false; bool _darkmode = false;
bool get darkMode => _darkmode; bool get darkMode => _darkmode;
ThemeColor _color = ThemeColor.Blue;
ThemeColor get color => _color;
void setDarkMode(bool v) { void setDarkMode(bool v) {
_darkmode = v; _darkmode = v;
notifyListeners(); notifyListeners();
save();
} }
void switchDarkMode() { void switchDarkMode() {
_darkmode = !_darkmode; _darkmode = !_darkmode;
notifyListeners(); notifyListeners();
save();
}
void setColor(ThemeColor v) {
_color = v;
notifyListeners();
save();
}
// --------------------------------------------------------------------------
void load() {
_darkmode = Globals().sharedPrefs.getBool('theme.dark') ?? _darkmode;
_color = ThemeColor.parse(Globals().sharedPrefs.getString('theme.color')) ?? _color;
}
Future<void> save() async {
await Globals().sharedPrefs.setBool('theme.dark', _darkmode);
await Globals().sharedPrefs.setString('theme.color', _color.key);
} }
} }

View File

@ -1,80 +1,117 @@
import 'dart:convert';
import 'dart:io';
import 'package:hive_flutter/hive_flutter.dart'; import 'package:hive_flutter/hive_flutter.dart';
import 'package:simplecloudnotifier/state/globals.dart';
import 'package:simplecloudnotifier/state/interfaces.dart'; import 'package:simplecloudnotifier/state/interfaces.dart';
import 'package:xid/xid.dart'; import 'package:xid/xid.dart';
import 'package:path/path.dart' as path;
part 'application_log.g.dart'; part 'application_log.g.dart';
class ApplicationLog { class ApplicationLog {
//TODO max size, auto clear old static const MAX_SIZE = 2048;
static void debug(String message, {String? additional, StackTrace? trace}) { static void debug(String message, {String? additional, StackTrace? trace}) {
(additional != null && additional != '') ? print('[DEBUG] ${message}: ${additional}') : print('[DEBUG] ${message}'); (additional != null && additional != '') ? print('[DEBUG] ${message}: ${additional}') : print('[DEBUG] ${message}');
if (!Hive.isBoxOpen('scn-logs')) return; _logToBox(SCNLogLevel.debug, message, additional, trace);
Hive.box<SCNLog>('scn-logs').add(SCNLog(
id: Xid().toString(),
timestamp: DateTime.now(),
level: SCNLogLevel.debug,
message: message,
additional: additional ?? '',
trace: trace?.toString() ?? '',
));
} }
static void info(String message, {String? additional, StackTrace? trace}) { static void info(String message, {String? additional, StackTrace? trace}) {
(additional != null && additional != '') ? print('[INFO] ${message}: ${additional}') : print('[INFO] ${message}'); (additional != null && additional != '') ? print('[INFO] ${message}: ${additional}') : print('[INFO] ${message}');
if (!Hive.isBoxOpen('scn-logs')) return; _logToBox(SCNLogLevel.info, message, additional, trace);
Hive.box<SCNLog>('scn-logs').add(SCNLog(
id: Xid().toString(),
timestamp: DateTime.now(),
level: SCNLogLevel.info,
message: message,
additional: additional ?? '',
trace: trace?.toString() ?? '',
));
} }
static void warn(String message, {String? additional, StackTrace? trace}) { static void warn(String message, {String? additional, StackTrace? trace}) {
(additional != null && additional != '') ? print('[WARN] ${message}: ${additional}') : print('[WARN] ${message}'); (additional != null && additional != '') ? print('[WARN] ${message}: ${additional}') : print('[WARN] ${message}');
if (!Hive.isBoxOpen('scn-logs')) return; _logToBox(SCNLogLevel.warning, message, additional, trace);
Hive.box<SCNLog>('scn-logs').add(SCNLog(
id: Xid().toString(),
timestamp: DateTime.now(),
level: SCNLogLevel.warning,
message: message,
additional: additional ?? '',
trace: trace?.toString() ?? '',
));
} }
static void error(String message, {String? additional, StackTrace? trace}) { static void error(String message, {String? additional, StackTrace? trace}) {
(additional != null && additional != '') ? print('[ERROR] ${message}: ${additional}') : print('[ERROR] ${message}'); (additional != null && additional != '') ? print('[ERROR] ${message}: ${additional}') : print('[ERROR] ${message}');
if (!Hive.isBoxOpen('scn-logs')) return; _logToBox(SCNLogLevel.error, message, additional, trace);
Hive.box<SCNLog>('scn-logs').add(SCNLog(
id: Xid().toString(),
timestamp: DateTime.now(),
level: SCNLogLevel.error,
message: message,
additional: additional ?? '',
trace: trace?.toString() ?? '',
));
} }
static void fatal(String message, {String? additional, StackTrace? trace}) { static void fatal(String message, {String? additional, StackTrace? trace}) {
(additional != null && additional != '') ? print('[FATAL] ${message}: ${additional}') : print('[FATAL] ${message}'); (additional != null && additional != '') ? print('[FATAL] ${message}: ${additional}') : print('[FATAL] ${message}');
_logToBox(SCNLogLevel.fatal, message, additional, trace);
}
static void _logToBox(SCNLogLevel lvl, String message, String? additional, StackTrace? trace) {
if (!Hive.isBoxOpen('scn-logs')) return; if (!Hive.isBoxOpen('scn-logs')) return;
Hive.box<SCNLog>('scn-logs').add(SCNLog(
final box = Hive.box<SCNLog>('scn-logs');
box.add(SCNLog(
id: Xid().toString(), id: Xid().toString(),
timestamp: DateTime.now(), timestamp: DateTime.now(),
level: SCNLogLevel.fatal, level: lvl,
message: message, message: message,
additional: additional ?? '', additional: additional ?? '',
trace: trace?.toString() ?? '', trace: trace?.toString() ?? '',
)); ));
while (box.length > MAX_SIZE) box.deleteAt(0);
}
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>";
} }
} }

View File

@ -5,10 +5,16 @@ import 'package:simplecloudnotifier/state/interfaces.dart';
part 'fb_message.g.dart'; part 'fb_message.g.dart';
class FBMessageLog { class FBMessageLog {
//TODO max size, auto clear old static const MAX_SIZE = 512;
static void insert(RemoteMessage msg) { static void insert(RemoteMessage msg) {
Hive.box<FBMessage>('scn-fb-messages').add(FBMessage.fromRemoteMessage(msg)); if (!Hive.isBoxOpen('scn-fb-messages')) return;
final box = Hive.box<FBMessage>('scn-fb-messages');
box.add(FBMessage.fromRemoteMessage(msg));
while (box.length > MAX_SIZE) box.deleteAt(0);
} }
} }

View File

@ -2,7 +2,9 @@ import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart'; import 'package:device_info_plus/device_info_plus.dart';
import 'package:package_info_plus/package_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:shared_preferences/shared_preferences.dart';
import 'package:path/path.dart' as path;
class Globals { class Globals {
static final Globals _singleton = Globals._internal(); static final Globals _singleton = Globals._internal();
@ -23,9 +25,13 @@ class Globals {
String hostname = ''; String hostname = '';
String clientType = ''; String clientType = '';
String deviceModel = ''; String deviceModel = '';
String deviceName = '';
late SharedPreferences sharedPrefs; late SharedPreferences sharedPrefs;
late Directory appDocumentsDir;
late Directory rawFailureLogsDir;
bool get isInitialized => _initialized; bool get isInitialized => _initialized;
Future<void> init() async { Future<void> init() async {
@ -43,24 +49,34 @@ class Globals {
if (Platform.isAndroid) { if (Platform.isAndroid) {
this.clientType = 'ANDROID'; this.clientType = 'ANDROID';
this.deviceModel = (await DeviceInfoPlugin().androidInfo).model; this.deviceModel = (await DeviceInfoPlugin().androidInfo).model;
this.deviceName = (await DeviceInfoPlugin().androidInfo).name;
} else if (Platform.isIOS) { } else if (Platform.isIOS) {
this.clientType = 'IOS'; this.clientType = 'IOS';
this.deviceModel = (await DeviceInfoPlugin().iosInfo).model; this.deviceModel = (await DeviceInfoPlugin().iosInfo).model;
this.deviceName = (await DeviceInfoPlugin().iosInfo).name;
} else if (Platform.isLinux) { } else if (Platform.isLinux) {
this.clientType = 'LINUX'; this.clientType = 'LINUX';
this.deviceModel = (await DeviceInfoPlugin().linuxInfo).prettyName; this.deviceModel = (await DeviceInfoPlugin().linuxInfo).prettyName;
this.deviceName = (await DeviceInfoPlugin().linuxInfo).name;
} else if (Platform.isWindows) { } else if (Platform.isWindows) {
this.clientType = 'WINDOWS'; this.clientType = 'WINDOWS';
this.deviceModel = (await DeviceInfoPlugin().windowsInfo).productName; this.deviceModel = (await DeviceInfoPlugin().windowsInfo).productName;
this.deviceName = (await DeviceInfoPlugin().windowsInfo).computerName;
} else if (Platform.isMacOS) { } else if (Platform.isMacOS) {
this.clientType = 'MACOS'; this.clientType = 'MACOS';
this.deviceModel = (await DeviceInfoPlugin().macOsInfo).model; this.deviceModel = (await DeviceInfoPlugin().macOsInfo).model;
this.deviceName = (await DeviceInfoPlugin().macOsInfo).computerName;
} else { } else {
this.clientType = '?'; this.clientType = '?';
} }
this.sharedPrefs = await SharedPreferences.getInstance(); 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; this._initialized = true;
} }

View File

@ -6,10 +6,10 @@ import 'package:xid/xid.dart';
part 'request_log.g.dart'; part 'request_log.g.dart';
class RequestLog { class RequestLog {
//TODO max size, auto clear old static const MAX_SIZE = 1024;
static void addRequestException(String name, DateTime tStart, String method, Uri uri, String reqbody, Map<String, String> reqheaders, dynamic e, StackTrace trace) { static void addRequestException(String name, DateTime tStart, String method, Uri uri, String reqbody, Map<String, String> reqheaders, dynamic e, StackTrace trace) {
Hive.box<SCNRequest>('scn-requests').add(SCNRequest( _logToBox(SCNRequest(
id: Xid().toString(), id: Xid().toString(),
timestampStart: tStart, timestampStart: tStart,
timestampEnd: DateTime.now(), timestampEnd: DateTime.now(),
@ -28,7 +28,7 @@ class RequestLog {
} }
static void addRequestAPIError(String name, DateTime t0, String method, Uri uri, String reqbody, Map<String, String> reqheaders, int responseStatusCode, String responseBody, Map<String, String> responseHeaders, APIError apierr) { static void addRequestAPIError(String name, DateTime t0, String method, Uri uri, String reqbody, Map<String, String> reqheaders, int responseStatusCode, String responseBody, Map<String, String> responseHeaders, APIError apierr) {
Hive.box<SCNRequest>('scn-requests').add(SCNRequest( _logToBox(SCNRequest(
id: Xid().toString(), id: Xid().toString(),
timestampStart: t0, timestampStart: t0,
timestampEnd: DateTime.now(), timestampEnd: DateTime.now(),
@ -47,7 +47,7 @@ class RequestLog {
} }
static void addRequestErrorStatuscode(String name, DateTime t0, String method, Uri uri, String reqbody, Map<String, String> reqheaders, int responseStatusCode, String responseBody, Map<String, String> responseHeaders) { static void addRequestErrorStatuscode(String name, DateTime t0, String method, Uri uri, String reqbody, Map<String, String> reqheaders, int responseStatusCode, String responseBody, Map<String, String> responseHeaders) {
Hive.box<SCNRequest>('scn-requests').add(SCNRequest( _logToBox(SCNRequest(
id: Xid().toString(), id: Xid().toString(),
timestampStart: t0, timestampStart: t0,
timestampEnd: DateTime.now(), timestampEnd: DateTime.now(),
@ -66,7 +66,7 @@ class RequestLog {
} }
static void addRequestSuccess(String name, DateTime t0, String method, Uri uri, String reqbody, Map<String, String> reqheaders, int responseStatusCode, String responseBody, Map<String, String> responseHeaders) { static void addRequestSuccess(String name, DateTime t0, String method, Uri uri, String reqbody, Map<String, String> reqheaders, int responseStatusCode, String responseBody, Map<String, String> responseHeaders) {
Hive.box<SCNRequest>('scn-requests').add(SCNRequest( _logToBox(SCNRequest(
id: Xid().toString(), id: Xid().toString(),
timestampStart: t0, timestampStart: t0,
timestampEnd: DateTime.now(), timestampEnd: DateTime.now(),
@ -85,7 +85,7 @@ class RequestLog {
} }
static void addRequestDecodeError(String name, DateTime t0, String method, Uri uri, String reqbody, Map<String, String> reqheaders, int responseStatusCode, String responseBody, Map<String, String> responseHeaders, Object exc, StackTrace trace) { static void addRequestDecodeError(String name, DateTime t0, String method, Uri uri, String reqbody, Map<String, String> reqheaders, int responseStatusCode, String responseBody, Map<String, String> responseHeaders, Object exc, StackTrace trace) {
Hive.box<SCNRequest>('scn-requests').add(SCNRequest( _logToBox(SCNRequest(
id: Xid().toString(), id: Xid().toString(),
timestampStart: t0, timestampStart: t0,
timestampEnd: DateTime.now(), timestampEnd: DateTime.now(),
@ -102,6 +102,16 @@ class RequestLog {
stackTrace: trace.toString(), stackTrace: trace.toString(),
)); ));
} }
static void _logToBox(SCNRequest v) {
if (!Hive.isBoxOpen('scn-requests')) return;
final box = Hive.box<SCNRequest>('scn-requests');
box.add(v);
while (box.length > MAX_SIZE) box.deleteAt(0);
}
} }
@HiveType(typeId: 100) @HiveType(typeId: 100)

View File

@ -1,7 +1,9 @@
import 'package:hive_flutter/hive_flutter.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/channel.dart';
import 'package:simplecloudnotifier/models/keytoken.dart';
import 'package:simplecloudnotifier/models/scn_message.dart'; import 'package:simplecloudnotifier/models/scn_message.dart';
import 'package:simplecloudnotifier/settings/app_settings.dart'; import 'package:simplecloudnotifier/state/app_settings.dart';
class SCNDataCache { class SCNDataCache {
SCNDataCache._internal(); SCNDataCache._internal();
@ -57,4 +59,21 @@ class SCNDataCache {
return cacheMessages; return cacheMessages;
} }
Future<KeyToken> getOrQueryTokenByValue(String uid, String tokVal) async {
final cache = Hive.box<KeyToken>('scn-keytoken-value-cache');
final cacheVal = cache.get(tokVal);
if (cacheVal != null) {
print('[SCNDataCache] Found Token(${tokVal}) in cache');
return Future.value(cacheVal);
}
final tok = await APIClient.getKeyTokenByToken(uid, tokVal);
print('[SCNDataCache] Queried Token(${tokVal}) from API');
await cache.put(tokVal, tok);
return tok;
}
} }

View File

@ -2,6 +2,8 @@
// Unfortunately Future.value(x) in FutureBuilder always results in one frame were snapshot.connectionState is waiting // Unfortunately Future.value(x) in FutureBuilder always results in one frame were snapshot.connectionState is waiting
// This way we can set the ImmediateFuture.value directly and circumvent that. // This way we can set the ImmediateFuture.value directly and circumvent that.
import 'dart:async';
class ImmediateFuture<T> { class ImmediateFuture<T> {
final Future<T> future; final Future<T> future;
final T? value; final T? value;
@ -20,6 +22,10 @@ class ImmediateFuture<T> {
: future = Future.value(v), : future = Future.value(v),
value = v; value = v;
ImmediateFuture.ofPending()
: future = Completer<T>().future,
value = null;
T? get() { T? get() {
return value ?? _futureValue; return value ?? _futureValue;
} }

View File

@ -0,0 +1,51 @@
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'),
),
],
),
);
}
static Future<bool> showConfirmDialog(BuildContext context, String title, {String? text, String? okText, String? cancelText}) {
return showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(title),
content: (text != null) ? Text(text) : null,
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(cancelText ?? 'Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(okText ?? 'OK'),
),
],
),
).then((value) => value ?? false);
}
}

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