Compare commits
39 Commits
refactor_s
...
develop
Author | SHA1 | Date | |
---|---|---|---|
80d4e18a23
|
|||
cc672d2f20
|
|||
1cf14e65a9
|
|||
9b2e429d3d
|
|||
2f73a21a41
|
|||
05eb37bc80
|
|||
779c86d8ac
|
|||
d9a14c9973
|
|||
7546c2a1a4
|
|||
d21d775764
|
|||
352f1ca0d1
|
|||
584a9e983f
|
|||
5dd94eca38
|
|||
d8c06e3de2
|
|||
3adeadf6fb
|
|||
9d35916280
|
|||
4c7632a144
|
|||
e329e13a02
|
|||
7ddaf5d9aa
|
|||
5da4c3d3b9
|
|||
fb1560a1f5
|
|||
61d62f736c
|
|||
77362f1651
|
|||
e93d125431
|
|||
74a935f6f1
|
|||
be7035978b
|
|||
778451fa4c
|
|||
89d1e0f641
|
|||
1f9b65652d
|
|||
2b23404461
|
|||
e2dbe8866d
|
|||
7dad61dbbb
|
|||
9542405512
|
|||
59d28d3c49
|
|||
600f3365f6
|
|||
5b8a1e86e0
|
|||
c8bc7665f7
|
|||
0bbe5fc7fa
|
|||
e9ea573e33
|
@@ -13,7 +13,7 @@ on:
|
|||||||
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build_job:
|
build_server:
|
||||||
name: Build Docker Container
|
name: Build Docker Container
|
||||||
runs-on: bfb-cicd-latest
|
runs-on: bfb-cicd-latest
|
||||||
steps:
|
steps:
|
||||||
@@ -24,9 +24,49 @@ jobs:
|
|||||||
- run: cd "${{ gitea.workspace }}/scnserver" && make docker
|
- run: cd "${{ gitea.workspace }}/scnserver" && make docker
|
||||||
- run: cd "${{ gitea.workspace }}/scnserver" && make push-docker
|
- run: cd "${{ gitea.workspace }}/scnserver" && make push-docker
|
||||||
|
|
||||||
deploy_job:
|
test_server:
|
||||||
|
name: Run Unit-Tests
|
||||||
|
runs-on: bfb-cicd-latest
|
||||||
|
steps:
|
||||||
|
|
||||||
|
- name: Check out code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Get Commiter Info
|
||||||
|
id: commiter_info
|
||||||
|
run: |
|
||||||
|
echo "NAME=$( git log -n 1 --pretty=format:%an )" >> $GITHUB_OUTPUT
|
||||||
|
echo "MAIL=$( git log -n 1 --pretty=format:%ae )" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: '${{ gitea.workspace }}/scnserver/go.mod'
|
||||||
|
cache: false
|
||||||
|
|
||||||
|
- name: Print Go Version
|
||||||
|
run: go version
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: cd "${{ gitea.workspace }}/scnserver" && make dgi && make swagger && SCN_TEST_LOGLEVEL=WARN make test
|
||||||
|
|
||||||
|
- name: Send failure mail
|
||||||
|
if: failure()
|
||||||
|
uses: dawidd6/action-send-mail@v3
|
||||||
|
with:
|
||||||
|
server_address: smtp.fastmail.com
|
||||||
|
server_port: 465
|
||||||
|
secure: true
|
||||||
|
username: ${{secrets.MAIL_USERNAME}}
|
||||||
|
password: ${{secrets.MAIL_PASSWORD}}
|
||||||
|
subject: Pipeline on '${{ gitea.repository }}' failed
|
||||||
|
to: ${{ steps.commiter_info.outputs.MAIL }}
|
||||||
|
from: Gitea Actions <gitea_actions@blackforestbytes.de>
|
||||||
|
body: "Go to https://gogs.blackforestbytes.com/${{ gitea.repository }}/actions"
|
||||||
|
|
||||||
|
deploy_server:
|
||||||
name: Deploy to Server
|
name: Deploy to Server
|
||||||
needs: [build_job]
|
needs: [build_server, test_server]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Execute deploy on remote (via ssh)
|
- name: Execute deploy on remote (via ssh)
|
||||||
|
@@ -1,9 +1,33 @@
|
|||||||
|
|
||||||
|
|
||||||
|
# Setup
|
||||||
|
#
|
||||||
|
# flutter config --jdk-dir "/usr/lib/jvm/default-runtime/bin"
|
||||||
|
# sudo archlinux-java set java-17-openjdk
|
||||||
|
#
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
run:
|
run:
|
||||||
flutter pub run build_runner build
|
flutter pub run build_runner build
|
||||||
flutter run
|
_JAVA_OPTIONS="" flutter run
|
||||||
|
|
||||||
|
run-android:
|
||||||
|
ping -c1 10.10.10.177
|
||||||
|
adb connect 10.10.10.177:5555
|
||||||
|
flutter pub run build_runner build
|
||||||
|
_JAVA_OPTIONS="" flutter run -d 10.10.10.177:5555
|
||||||
|
|
||||||
|
install-release:
|
||||||
|
# Install on Pixel 7a
|
||||||
|
flutter build apk --release
|
||||||
|
flutter run --release -d 35221JEHN07157
|
||||||
|
|
||||||
|
build-release:
|
||||||
|
flutter build apk --release
|
||||||
|
flutter build appbundle --release
|
||||||
|
flutter build linux --release
|
||||||
|
|
||||||
test:
|
test:
|
||||||
dart analyze
|
dart analyze
|
||||||
|
|
||||||
@@ -18,4 +42,8 @@ autoreload:
|
|||||||
@_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
|
@@ -25,6 +25,8 @@
|
|||||||
|
|
||||||
- [ ] 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?
|
||||||
|
|
||||||
|
- [ ] fix time format (in message-list, in card, top right) - midnight is shown as "24:05" instead of "00:05" - thats weird
|
||||||
|
|
||||||
-----
|
-----
|
||||||
|
|
||||||
# TODO iOS specific
|
# TODO iOS specific
|
||||||
|
@@ -37,12 +37,13 @@ android {
|
|||||||
ndkVersion flutter.ndkVersion
|
ndkVersion flutter.ndkVersion
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility JavaVersion.VERSION_1_8
|
coreLibraryDesugaringEnabled true
|
||||||
targetCompatibility JavaVersion.VERSION_1_8
|
sourceCompatibility JavaVersion.VERSION_17
|
||||||
|
targetCompatibility JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
jvmTarget = '1.8'
|
jvmTarget = '17'
|
||||||
}
|
}
|
||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
@@ -55,6 +56,7 @@ android {
|
|||||||
targetSdkVersion flutter.targetSdkVersion
|
targetSdkVersion flutter.targetSdkVersion
|
||||||
versionCode flutterVersionCode.toInteger()
|
versionCode flutterVersionCode.toInteger()
|
||||||
versionName flutterVersionName
|
versionName flutterVersionName
|
||||||
|
multiDexEnabled true
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
@@ -77,4 +79,9 @@ flutter {
|
|||||||
source '../..'
|
source '../..'
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {}
|
dependencies {
|
||||||
|
implementation 'androidx.window:window:1.0.0'
|
||||||
|
implementation 'androidx.window:window-java:1.0.0'
|
||||||
|
|
||||||
|
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.2.2'
|
||||||
|
}
|
||||||
|
27
flutter/android/app/proguard-rules.pro
vendored
Normal file
27
flutter/android/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
## Gson rules
|
||||||
|
# Gson uses generic type information stored in a class file when working with fields. Proguard
|
||||||
|
# removes such information by default, so configure it to keep all of it.
|
||||||
|
-keepattributes Signature
|
||||||
|
|
||||||
|
# For using GSON @Expose annotation
|
||||||
|
-keepattributes *Annotation*
|
||||||
|
|
||||||
|
# Gson specific classes
|
||||||
|
-dontwarn sun.misc.**
|
||||||
|
#-keep class com.google.gson.stream.** { *; }
|
||||||
|
|
||||||
|
# Prevent proguard from stripping interface information from TypeAdapter, TypeAdapterFactory,
|
||||||
|
# JsonSerializer, JsonDeserializer instances (so they can be used in @JsonAdapter)
|
||||||
|
-keep class * extends com.google.gson.TypeAdapter
|
||||||
|
-keep class * implements com.google.gson.TypeAdapterFactory
|
||||||
|
-keep class * implements com.google.gson.JsonSerializer
|
||||||
|
-keep class * implements com.google.gson.JsonDeserializer
|
||||||
|
|
||||||
|
# Prevent R8 from leaving Data object members always null
|
||||||
|
-keepclassmembers,allowobfuscation class * {
|
||||||
|
@com.google.gson.annotations.SerializedName <fields>;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Retain generic signatures of TypeToken and its subclasses with R8 version 3.0 and higher.
|
||||||
|
-keep,allowobfuscation,allowshrinking class com.google.gson.reflect.TypeToken
|
||||||
|
-keep,allowobfuscation,allowshrinking class * extends com.google.gson.reflect.TypeToken
|
@@ -31,6 +31,9 @@
|
|||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ActionBroadcastReceiver" />
|
||||||
|
|
||||||
<!-- Don't delete the meta-data below.
|
<!-- Don't delete the meta-data below.
|
||||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
<meta-data
|
<meta-data
|
||||||
|
Binary file not shown.
After Width: | Height: | Size: 472 B |
Binary file not shown.
After Width: | Height: | Size: 320 B |
@@ -0,0 +1,34 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportHeight="108"
|
||||||
|
android:viewportWidth="108">
|
||||||
|
<path
|
||||||
|
android:fillType="evenOdd"
|
||||||
|
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
|
||||||
|
android:strokeColor="#00000000"
|
||||||
|
android:strokeWidth="1">
|
||||||
|
<aapt:attr name="android:fillColor">
|
||||||
|
<gradient
|
||||||
|
android:endX="78.5885"
|
||||||
|
android:endY="90.9159"
|
||||||
|
android:startX="48.7653"
|
||||||
|
android:startY="61.0927"
|
||||||
|
android:type="linear">
|
||||||
|
<item
|
||||||
|
android:color="#44000000"
|
||||||
|
android:offset="0.0" />
|
||||||
|
<item
|
||||||
|
android:color="#00000000"
|
||||||
|
android:offset="1.0" />
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:fillType="nonZero"
|
||||||
|
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
|
||||||
|
android:strokeColor="#00000000"
|
||||||
|
android:strokeWidth="1" />
|
||||||
|
</vector>
|
Binary file not shown.
After Width: | Height: | Size: 551 B |
Binary file not shown.
After Width: | Height: | Size: 949 B |
Binary file not shown.
After Width: | Height: | Size: 1.0 KiB |
2
flutter/android/app/src/main/res/raw/keep.xml
Normal file
2
flutter/android/app/src/main/res/raw/keep.xml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources xmlns:tools="http://schemas.android.com/tools" tools:keep="@drawable/*" />
|
@@ -6,6 +6,7 @@ buildscript {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
classpath 'com.android.tools.build:gradle:7.3.1'
|
||||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -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-7.6.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
|
|
||||||
|
@@ -1,13 +1,27 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
import Flutter
|
import Flutter
|
||||||
|
import flutter_local_notifications
|
||||||
|
|
||||||
@UIApplicationMain
|
@UIApplicationMain
|
||||||
@objc class AppDelegate: FlutterAppDelegate {
|
@objc class AppDelegate: FlutterAppDelegate {
|
||||||
|
|
||||||
override func application(
|
override func application(
|
||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
|
|
||||||
|
// This is required to make any communication available in the action isolate.
|
||||||
|
FlutterLocalNotificationsPlugin.setPluginRegistrantCallback { (registry) in
|
||||||
|
GeneratedPluginRegistrant.register(with: registry)
|
||||||
|
}
|
||||||
|
|
||||||
|
if #available(iOS 10.0, *) {
|
||||||
|
UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate
|
||||||
|
}
|
||||||
|
|
||||||
GeneratedPluginRegistrant.register(with: self)
|
GeneratedPluginRegistrant.register(with: self)
|
||||||
|
|
||||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@@ -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>
|
||||||
|
@@ -5,13 +5,15 @@ 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/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/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/request_log.dart';
|
import 'package:simplecloudnotifier/state/request_log.dart';
|
||||||
import 'package:simplecloudnotifier/models/channel.dart';
|
import 'package:simplecloudnotifier/models/channel.dart';
|
||||||
import 'package:simplecloudnotifier/models/message.dart';
|
import 'package:simplecloudnotifier/models/scn_message.dart';
|
||||||
import 'package:simplecloudnotifier/state/token_source.dart';
|
import 'package:simplecloudnotifier/state/token_source.dart';
|
||||||
import 'package:simplecloudnotifier/utils/toaster.dart';
|
import 'package:simplecloudnotifier/utils/toaster.dart';
|
||||||
|
|
||||||
@@ -26,6 +28,27 @@ enum ChannelSelector {
|
|||||||
final String apiKey;
|
final String apiKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class MessageFilter {
|
||||||
|
List<String>? channelIDs;
|
||||||
|
List<String>? searchFilter;
|
||||||
|
List<String>? senderNames;
|
||||||
|
List<String>? usedKeys;
|
||||||
|
List<int>? priority;
|
||||||
|
DateTime? timeBefore;
|
||||||
|
DateTime? timeAfter;
|
||||||
|
bool? hasSenderName;
|
||||||
|
|
||||||
|
MessageFilter({
|
||||||
|
this.channelIDs,
|
||||||
|
this.searchFilter,
|
||||||
|
this.senderNames,
|
||||||
|
this.usedKeys,
|
||||||
|
this.priority,
|
||||||
|
this.timeBefore,
|
||||||
|
this.timeAfter,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
class APIClient {
|
class APIClient {
|
||||||
static const String _base = 'https://simplecloudnotifier.de/api/v2';
|
static const String _base = 'https://simplecloudnotifier.de/api/v2';
|
||||||
|
|
||||||
@@ -33,7 +56,7 @@ class APIClient {
|
|||||||
required String name,
|
required String name,
|
||||||
required String method,
|
required String method,
|
||||||
required String relURL,
|
required String relURL,
|
||||||
Map<String, String>? query,
|
Map<String, Iterable<String>>? query,
|
||||||
required T Function(Map<String, dynamic> json)? fn,
|
required T Function(Map<String, dynamic> json)? fn,
|
||||||
dynamic jsonBody,
|
dynamic jsonBody,
|
||||||
String? authToken,
|
String? authToken,
|
||||||
@@ -45,7 +68,7 @@ class APIClient {
|
|||||||
|
|
||||||
final req = http.Request(method, uri);
|
final req = http.Request(method, uri);
|
||||||
|
|
||||||
print('[REQUEST|RUN] [${method}] ${name}');
|
print('[REQUEST|RUN] [${method}] ${name} | ${uri.toString()}');
|
||||||
|
|
||||||
if (jsonBody != null) {
|
if (jsonBody != null) {
|
||||||
req.body = jsonEncode(jsonBody);
|
req.body = jsonEncode(jsonBody);
|
||||||
@@ -79,19 +102,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 {
|
||||||
@@ -185,7 +210,9 @@ class APIClient {
|
|||||||
name: 'getChannelList',
|
name: 'getChannelList',
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
relURL: 'users/${auth.getUserID()}/channels',
|
relURL: 'users/${auth.getUserID()}/channels',
|
||||||
query: {'selector': sel.apiKey},
|
query: {
|
||||||
|
'selector': [sel.apiKey]
|
||||||
|
},
|
||||||
fn: (json) => ChannelWithSubscription.fromJsonArray(json['channels'] as List<dynamic>),
|
fn: (json) => ChannelWithSubscription.fromJsonArray(json['channels'] as List<dynamic>),
|
||||||
authToken: auth.getToken(),
|
authToken: auth.getToken(),
|
||||||
);
|
);
|
||||||
@@ -211,28 +238,62 @@ class APIClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<(String, List<Message>)> getMessageList(TokenSource auth, String pageToken, {int? pageSize, List<String>? channelIDs}) async {
|
static Future<ChannelWithSubscription> updateChannel(AppAuth auth, String cid, {String? displayName, String? descriptionName}) async {
|
||||||
|
return await _request(
|
||||||
|
name: 'updateChannel',
|
||||||
|
method: 'PATCH',
|
||||||
|
relURL: 'users/${auth.getUserID()}/channels/${cid}',
|
||||||
|
jsonBody: {
|
||||||
|
if (displayName != null) 'display_name': displayName,
|
||||||
|
if (descriptionName != null) 'description_name': descriptionName,
|
||||||
|
},
|
||||||
|
fn: ChannelWithSubscription.fromJson,
|
||||||
|
authToken: auth.getToken(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<(String, List<SCNMessage>)> getMessageList(TokenSource auth, String pageToken, {int? pageSize, MessageFilter? filter}) async {
|
||||||
return await _request(
|
return await _request(
|
||||||
name: 'getMessageList',
|
name: 'getMessageList',
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
relURL: 'messages',
|
relURL: 'messages',
|
||||||
query: {
|
query: {
|
||||||
'next_page_token': pageToken,
|
'next_page_token': [pageToken],
|
||||||
if (pageSize != null) 'page_size': pageSize.toString(),
|
if (pageSize != null) 'page_size': [pageSize.toString()],
|
||||||
if (channelIDs != null) 'channel_id': channelIDs.join(","),
|
if (filter?.searchFilter != null) 'search': filter!.searchFilter!,
|
||||||
|
if (filter?.channelIDs != null) 'channel_id': filter!.channelIDs!,
|
||||||
|
if (filter?.senderNames != null) 'sender': filter!.senderNames!,
|
||||||
|
if (filter?.hasSenderName != null) 'has_sender': [filter!.hasSenderName!.toString()],
|
||||||
|
if (filter?.timeBefore != null) 'before': [filter!.timeBefore!.toIso8601String()],
|
||||||
|
if (filter?.timeAfter != null) 'after': [filter!.timeAfter!.toIso8601String()],
|
||||||
|
if (filter?.priority != null) 'priority': filter!.priority!.map((p) => p.toString()).toList(),
|
||||||
|
if (filter?.usedKeys != null) 'used_key': filter!.usedKeys!,
|
||||||
},
|
},
|
||||||
fn: (json) => Message.fromPaginatedJsonArray(json, 'messages', 'next_page_token'),
|
fn: (json) => SCNMessage.fromPaginatedJsonArray(json, 'messages', 'next_page_token'),
|
||||||
authToken: auth.getToken(),
|
authToken: auth.getToken(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<Message> getMessage(TokenSource auth, String msgid) async {
|
static Future<SCNMessage> getMessage(TokenSource auth, String msgid) async {
|
||||||
return await _request(
|
return await _request(
|
||||||
name: 'getMessage',
|
name: 'getMessage',
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
relURL: 'messages/$msgid',
|
relURL: 'messages/$msgid',
|
||||||
query: {},
|
fn: SCNMessage.fromJson,
|
||||||
fn: Message.fromJson,
|
authToken: auth.getToken(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<(String, List<SCNMessage>)> getChannelMessageList(TokenSource auth, String cid, String pageToken, {int? pageSize}) async {
|
||||||
|
return await _request(
|
||||||
|
name: 'getChannelMessageList',
|
||||||
|
method: 'GET',
|
||||||
|
relURL: 'users/${auth.getUserID()}/channels/${cid}/messages',
|
||||||
|
query: {
|
||||||
|
'next_page_token': [pageToken],
|
||||||
|
if (pageSize != null) 'page_size': [pageSize.toString()],
|
||||||
|
},
|
||||||
|
fn: (json) => SCNMessage.fromPaginatedJsonArray(json, 'messages', 'next_page_token'),
|
||||||
authToken: auth.getToken(),
|
authToken: auth.getToken(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -247,6 +308,16 @@ class APIClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<List<Subscription>> getChannelSubscriptions(TokenSource auth, String cid) async {
|
||||||
|
return await _request(
|
||||||
|
name: 'getChannelSubscriptions',
|
||||||
|
method: 'GET',
|
||||||
|
relURL: 'users/${auth.getUserID()}/channels/${cid}/subscriptions',
|
||||||
|
fn: (json) => Subscription.fromJsonArray(json['subscriptions'] as List<dynamic>),
|
||||||
|
authToken: auth.getToken(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
static Future<List<Client>> getClientList(TokenSource auth) async {
|
static Future<List<Client>> getClientList(TokenSource auth) async {
|
||||||
return await _request(
|
return await _request(
|
||||||
name: 'getClientList',
|
name: 'getClientList',
|
||||||
@@ -314,4 +385,63 @@ class APIClient {
|
|||||||
authToken: token,
|
authToken: token,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<List<SenderNameStatistics>> getSenderNameList(TokenSource auth) async {
|
||||||
|
return await _request(
|
||||||
|
name: 'getSenderNameList',
|
||||||
|
method: 'GET',
|
||||||
|
relURL: 'users/${auth.getUserID()}/sender-names',
|
||||||
|
fn: (json) => SenderNameStatistics.fromJsonArray(json['sender_names'] as List<dynamic>),
|
||||||
|
authToken: auth.getToken(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<Subscription> subscribeToChannelbyID(TokenSource auth, String channelID) async {
|
||||||
|
return await _request(
|
||||||
|
name: 'subscribeToChannelbyID',
|
||||||
|
method: 'POST',
|
||||||
|
relURL: 'users/${auth.getUserID()}/subscriptions',
|
||||||
|
jsonBody: {
|
||||||
|
'channel_id': channelID,
|
||||||
|
},
|
||||||
|
fn: Subscription.fromJson,
|
||||||
|
authToken: auth.getToken(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<Subscription> deleteSubscription(TokenSource auth, String channelID, String subID) async {
|
||||||
|
return await _request(
|
||||||
|
name: 'deleteSubscription',
|
||||||
|
method: 'DELETE',
|
||||||
|
relURL: 'users/${auth.getUserID()}/subscriptions/${subID}',
|
||||||
|
fn: Subscription.fromJson,
|
||||||
|
authToken: auth.getToken(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<Subscription> confirmSubscription(TokenSource auth, String channelID, String subID) async {
|
||||||
|
return await _request(
|
||||||
|
name: 'confirmSubscription',
|
||||||
|
method: 'PATCH',
|
||||||
|
relURL: 'users/${auth.getUserID()}/subscriptions/${subID}',
|
||||||
|
jsonBody: {
|
||||||
|
'confirmed': true,
|
||||||
|
},
|
||||||
|
fn: Subscription.fromJson,
|
||||||
|
authToken: auth.getToken(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<Subscription> unconfirmSubscription(TokenSource auth, String channelID, String subID) async {
|
||||||
|
return await _request(
|
||||||
|
name: 'unconfirmSubscription',
|
||||||
|
method: 'PATCH',
|
||||||
|
relURL: 'users/${auth.getUserID()}/subscriptions/${subID}',
|
||||||
|
jsonBody: {
|
||||||
|
'confirmed': false,
|
||||||
|
},
|
||||||
|
fn: Subscription.fromJson,
|
||||||
|
authToken: auth.getToken(),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -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);
|
||||||
|
@@ -1,81 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
// https://stackoverflow.com/questions/46480221/flutter-floating-action-button-with-speed-dail
|
|
||||||
|
|
||||||
class FabWithIcons extends StatefulWidget {
|
|
||||||
FabWithIcons({super.key, required this.icons, required this.onIconTapped});
|
|
||||||
final List<IconData> icons;
|
|
||||||
final ValueChanged<int> onIconTapped;
|
|
||||||
|
|
||||||
@override
|
|
||||||
State createState() => FabWithIconsState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class FabWithIconsState extends State<FabWithIcons> with TickerProviderStateMixin {
|
|
||||||
late AnimationController _controller;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_controller = AnimationController(
|
|
||||||
vsync: this,
|
|
||||||
duration: const Duration(milliseconds: 250),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: List.generate(widget.icons.length, (int index) {
|
|
||||||
return _buildChild(index);
|
|
||||||
}).toList()
|
|
||||||
..add(
|
|
||||||
_buildFab(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildChild(int index) {
|
|
||||||
Color backgroundColor = Theme.of(context).cardColor;
|
|
||||||
Color foregroundColor = Theme.of(context).secondaryHeaderColor;
|
|
||||||
return Container(
|
|
||||||
height: 70.0,
|
|
||||||
width: 56.0,
|
|
||||||
alignment: FractionalOffset.topCenter,
|
|
||||||
child: ScaleTransition(
|
|
||||||
scale: CurvedAnimation(
|
|
||||||
parent: _controller,
|
|
||||||
curve: Interval(0.0, 1.0 - index / widget.icons.length / 2.0, curve: Curves.easeOut),
|
|
||||||
),
|
|
||||||
child: FloatingActionButton(
|
|
||||||
backgroundColor: backgroundColor,
|
|
||||||
mini: true,
|
|
||||||
child: Icon(widget.icons[index], color: foregroundColor),
|
|
||||||
onPressed: () => _onTapped(index),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildFab() {
|
|
||||||
return FloatingActionButton(
|
|
||||||
onPressed: () {
|
|
||||||
if (_controller.isDismissed) {
|
|
||||||
_controller.forward();
|
|
||||||
} else {
|
|
||||||
_controller.reverse();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
tooltip: 'Increment',
|
|
||||||
elevation: 2.0,
|
|
||||||
child: const Icon(Icons.add),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onTapped(int index) {
|
|
||||||
_controller.reverse();
|
|
||||||
widget.onIconTapped(index);
|
|
||||||
}
|
|
||||||
}
|
|
@@ -3,17 +3,20 @@ import 'package:flutter/material.dart';
|
|||||||
class HidableFAB extends StatelessWidget {
|
class HidableFAB extends StatelessWidget {
|
||||||
final VoidCallback? onPressed;
|
final VoidCallback? onPressed;
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
|
final Object heroTag;
|
||||||
|
|
||||||
const HidableFAB({
|
const HidableFAB({
|
||||||
super.key,
|
super.key,
|
||||||
this.onPressed,
|
this.onPressed,
|
||||||
required this.icon,
|
required this.icon,
|
||||||
|
required this.heroTag,
|
||||||
});
|
});
|
||||||
|
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Visibility(
|
return Visibility(
|
||||||
visible: MediaQuery.viewInsetsOf(context).bottom == 0.0, // hide when keyboard is shown
|
visible: MediaQuery.viewInsetsOf(context).bottom == 0.0, // hide when keyboard is shown
|
||||||
child: FloatingActionButton(
|
child: FloatingActionButton(
|
||||||
|
heroTag: this.heroTag,
|
||||||
onPressed: onPressed,
|
onPressed: onPressed,
|
||||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(17))),
|
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(17))),
|
||||||
elevation: 2.0,
|
elevation: 2.0,
|
||||||
|
@@ -1,18 +1,21 @@
|
|||||||
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:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:simplecloudnotifier/components/layout/app_bar_filter_dialog.dart';
|
||||||
import 'package:simplecloudnotifier/components/layout/app_bar_progress_indicator.dart';
|
import 'package:simplecloudnotifier/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/settings/app_settings.dart';
|
||||||
|
import 'package:simplecloudnotifier/state/app_bar_state.dart';
|
||||||
|
import 'package:simplecloudnotifier/state/app_events.dart';
|
||||||
import 'package:simplecloudnotifier/state/app_theme.dart';
|
import 'package:simplecloudnotifier/state/app_theme.dart';
|
||||||
import 'package:simplecloudnotifier/utils/navi.dart';
|
import 'package:simplecloudnotifier/utils/navi.dart';
|
||||||
|
|
||||||
class SCNAppBar extends StatelessWidget implements PreferredSizeWidget {
|
class SCNAppBar extends StatefulWidget implements PreferredSizeWidget {
|
||||||
const SCNAppBar({
|
SCNAppBar({
|
||||||
Key? key,
|
Key? key,
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.showThemeSwitch,
|
required this.showThemeSwitch,
|
||||||
required this.showDebug,
|
|
||||||
required this.showSearch,
|
required this.showSearch,
|
||||||
required this.showShare,
|
required this.showShare,
|
||||||
this.onShare = null,
|
this.onShare = null,
|
||||||
@@ -20,16 +23,33 @@ class SCNAppBar extends StatelessWidget implements PreferredSizeWidget {
|
|||||||
|
|
||||||
final String? title;
|
final String? title;
|
||||||
final bool showThemeSwitch;
|
final bool showThemeSwitch;
|
||||||
final bool showDebug;
|
|
||||||
final bool showSearch;
|
final bool showSearch;
|
||||||
final bool showShare;
|
final bool showShare;
|
||||||
final void Function()? onShare;
|
final void Function()? onShare;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SCNAppBar> createState() => _SCNAppBarState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SCNAppBarState extends State<SCNAppBar> {
|
||||||
|
final TextEditingController _ctrlSearchField = TextEditingController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_ctrlSearchField.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final cfg = Provider.of<AppSettings>(context);
|
||||||
|
|
||||||
var actions = <Widget>[];
|
var actions = <Widget>[];
|
||||||
|
|
||||||
if (showDebug) {
|
if (cfg.showDebugButton) {
|
||||||
actions.add(IconButton(
|
actions.add(IconButton(
|
||||||
icon: const Icon(FontAwesomeIcons.solidSpiderBlackWidow),
|
icon: const Icon(FontAwesomeIcons.solidSpiderBlackWidow),
|
||||||
tooltip: 'Debug',
|
tooltip: 'Debug',
|
||||||
@@ -39,7 +59,7 @@ class SCNAppBar extends StatelessWidget implements PreferredSizeWidget {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showThemeSwitch) {
|
if (widget.showThemeSwitch) {
|
||||||
actions.add(Consumer<AppTheme>(
|
actions.add(Consumer<AppTheme>(
|
||||||
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),
|
||||||
@@ -48,54 +68,118 @@ class SCNAppBar extends StatelessWidget implements PreferredSizeWidget {
|
|||||||
),
|
),
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
actions.add(Visibility(
|
actions.add(_buildSpacer());
|
||||||
visible: false,
|
|
||||||
maintainSize: true,
|
|
||||||
maintainAnimation: true,
|
|
||||||
maintainState: true,
|
|
||||||
child: IconButton(
|
|
||||||
icon: const Icon(FontAwesomeIcons.square),
|
|
||||||
onPressed: () {/*TODO*/},
|
|
||||||
),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showSearch) {
|
if (widget.showSearch) {
|
||||||
|
actions.add(IconButton(
|
||||||
|
icon: const Icon(FontAwesomeIcons.solidFilter),
|
||||||
|
tooltip: 'Filter',
|
||||||
|
onPressed: () => _showFilterDialog(context),
|
||||||
|
));
|
||||||
actions.add(IconButton(
|
actions.add(IconButton(
|
||||||
icon: const Icon(FontAwesomeIcons.solidMagnifyingGlass),
|
icon: const Icon(FontAwesomeIcons.solidMagnifyingGlass),
|
||||||
tooltip: 'Search',
|
tooltip: 'Search',
|
||||||
onPressed: () {/*TODO*/},
|
onPressed: () => AppBarState().setShowSearchField(true),
|
||||||
));
|
));
|
||||||
} else if (showShare) {
|
} else if (widget.showShare) {
|
||||||
|
actions.add(_buildSpacer());
|
||||||
actions.add(IconButton(
|
actions.add(IconButton(
|
||||||
icon: const Icon(FontAwesomeIcons.solidShareNodes),
|
icon: const Icon(FontAwesomeIcons.solidShareNodes),
|
||||||
tooltip: 'Share',
|
tooltip: 'Share',
|
||||||
onPressed: onShare ?? () {},
|
onPressed: widget.onShare ?? () {},
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
actions.add(Visibility(
|
actions.add(_buildSpacer());
|
||||||
visible: false,
|
actions.add(_buildSpacer());
|
||||||
maintainSize: true,
|
|
||||||
maintainAnimation: true,
|
|
||||||
maintainState: true,
|
|
||||||
child: IconButton(
|
|
||||||
icon: const Icon(FontAwesomeIcons.square),
|
|
||||||
onPressed: () {/*TODO*/},
|
|
||||||
),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return AppBar(
|
return Consumer<AppBarState>(builder: (context, value, child) {
|
||||||
title: Text(title ?? 'Simple Cloud Notifier 2.0'),
|
if (value.showSearchField) {
|
||||||
actions: actions,
|
return AppBar(
|
||||||
backgroundColor: Theme.of(context).secondaryHeaderColor,
|
leading: IconButton(
|
||||||
bottom: PreferredSize(
|
icon: const Icon(FontAwesomeIcons.solidArrowLeft),
|
||||||
preferredSize: Size(double.infinity, 1.0),
|
onPressed: () {
|
||||||
child: AppBarProgressIndicator(),
|
value.setShowSearchField(false);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
title: _buildSearchTextField(context),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(FontAwesomeIcons.solidMagnifyingGlass),
|
||||||
|
onPressed: () {
|
||||||
|
value.setShowSearchField(false);
|
||||||
|
final chiplet = MessageFilterChiplet(label: _ctrlSearchField.text, value: _ctrlSearchField.text, type: MessageFilterChipletType.search);
|
||||||
|
AppEvents().notifyFilterListeners([MessageFilterChipletType.search], [chiplet]);
|
||||||
|
_ctrlSearchField.clear();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
backgroundColor: Theme.of(context).secondaryHeaderColor,
|
||||||
|
bottom: PreferredSize(
|
||||||
|
preferredSize: Size(double.infinity, 1.0),
|
||||||
|
child: AppBarProgressIndicator(show: value.loadingIndeterminate),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return AppBar(
|
||||||
|
title: Text(widget.title ?? 'SCN'),
|
||||||
|
actions: actions,
|
||||||
|
backgroundColor: Theme.of(context).secondaryHeaderColor,
|
||||||
|
bottom: PreferredSize(
|
||||||
|
preferredSize: Size(double.infinity, 1.0),
|
||||||
|
child: AppBarProgressIndicator(show: value.loadingIndeterminate),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Visibility _buildSpacer() {
|
||||||
|
return Visibility(
|
||||||
|
visible: false,
|
||||||
|
maintainSize: true,
|
||||||
|
maintainAnimation: true,
|
||||||
|
maintainState: true,
|
||||||
|
child: IconButton(
|
||||||
|
icon: const Icon(FontAwesomeIcons.square),
|
||||||
|
onPressed: () {/* NO-OP */},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
Widget _buildSearchTextField(BuildContext context) {
|
||||||
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
|
return TextField(
|
||||||
|
controller: _ctrlSearchField,
|
||||||
|
autofocus: true,
|
||||||
|
style: TextStyle(fontSize: 20),
|
||||||
|
textInputAction: TextInputAction.search,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Search',
|
||||||
|
),
|
||||||
|
onSubmitted: (value) {
|
||||||
|
AppBarState().setShowSearchField(false);
|
||||||
|
final chiplet = MessageFilterChiplet(label: _ctrlSearchField.text, value: _ctrlSearchField.text, type: MessageFilterChipletType.search);
|
||||||
|
AppEvents().notifyFilterListeners([MessageFilterChipletType.search], [chiplet]);
|
||||||
|
_ctrlSearchField.clear();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showFilterDialog(BuildContext context) {
|
||||||
|
showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: true,
|
||||||
|
barrierColor: Colors.transparent,
|
||||||
|
builder: (BuildContext context) {
|
||||||
|
return Dialog(
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(0)),
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
insetPadding: EdgeInsets.fromLTRB(0, this.widget.preferredSize.height, 0, 0),
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
child: AppBarFilterDialog(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
122
flutter/lib/components/layout/app_bar_filter_dialog.dart
Normal file
122
flutter/lib/components/layout/app_bar_filter_dialog.dart
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||||
|
import 'package:simplecloudnotifier/components/modals/filter_modal_channel.dart';
|
||||||
|
import 'package:simplecloudnotifier/components/modals/filter_modal_keytoken.dart';
|
||||||
|
import 'package:simplecloudnotifier/components/modals/filter_modal_priority.dart';
|
||||||
|
import 'package:simplecloudnotifier/components/modals/filter_modal_sendername.dart';
|
||||||
|
import 'package:simplecloudnotifier/components/modals/filter_modal_time.dart';
|
||||||
|
import 'package:simplecloudnotifier/state/app_bar_state.dart';
|
||||||
|
import 'package:simplecloudnotifier/utils/navi.dart';
|
||||||
|
|
||||||
|
class AppBarFilterDialog extends StatefulWidget {
|
||||||
|
@override
|
||||||
|
_AppBarFilterDialogState createState() => _AppBarFilterDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AppBarFilterDialogState extends State<AppBarFilterDialog> {
|
||||||
|
double _height = 0;
|
||||||
|
|
||||||
|
double _targetHeight = 4 + (48 * 6) + (16 * 5) + 4;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
Future.delayed(Duration.zero, () {
|
||||||
|
setState(() {
|
||||||
|
_height = _targetHeight;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
double vpWidth = MediaQuery.sizeOf(context).width;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.all(0),
|
||||||
|
width: vpWidth,
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
color: Theme.of(context).secondaryHeaderColor,
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: Duration(milliseconds: 350),
|
||||||
|
curve: Curves.easeInCubic,
|
||||||
|
height: _height,
|
||||||
|
child: ClipRect(
|
||||||
|
child: OverflowBox(
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
maxWidth: vpWidth,
|
||||||
|
minWidth: vpWidth,
|
||||||
|
minHeight: 0,
|
||||||
|
maxHeight: _targetHeight,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
SizedBox(height: 4),
|
||||||
|
_buildFilterItem(context, FontAwesomeIcons.magnifyingGlass, 'Search', _showSearch),
|
||||||
|
Divider(),
|
||||||
|
_buildFilterItem(context, FontAwesomeIcons.snake, 'Channel', _showChannelModal),
|
||||||
|
Divider(),
|
||||||
|
_buildFilterItem(context, FontAwesomeIcons.signature, 'Sender', _showSenderModal),
|
||||||
|
Divider(),
|
||||||
|
_buildFilterItem(context, FontAwesomeIcons.timer, 'Time', _showTimeModal),
|
||||||
|
Divider(),
|
||||||
|
_buildFilterItem(context, FontAwesomeIcons.bolt, 'Priority', _showPriorityModal),
|
||||||
|
Divider(),
|
||||||
|
_buildFilterItem(context, FontAwesomeIcons.gearCode, 'Key', _showKeytokenModal),
|
||||||
|
Divider(),
|
||||||
|
_buildFilterItem(context, FontAwesomeIcons.magnifyingGlassPlus, 'Search (Plain)', _showPlainSearchModal),
|
||||||
|
SizedBox(height: 4),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(child: GestureDetector(child: Container(width: vpWidth, color: Color(0x88000000)), onTap: () => Navi.popDialog(context))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFilterItem(BuildContext context, IconData icon, String label, void Function(BuildContext context) action) {
|
||||||
|
return ListTile(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
title: Text(label),
|
||||||
|
leading: Icon(icon),
|
||||||
|
onTap: () {
|
||||||
|
Navi.popDialog(context);
|
||||||
|
action(context);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showSearch(BuildContext context) {
|
||||||
|
AppBarState().setShowSearchField(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showPriorityModal(BuildContext context) {
|
||||||
|
showDialog<void>(context: context, builder: (BuildContext context) => FilterModalPriority());
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showChannelModal(BuildContext context) {
|
||||||
|
showDialog<void>(context: context, builder: (BuildContext context) => FilterModalChannel());
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showSenderModal(BuildContext context) {
|
||||||
|
showDialog<void>(context: context, builder: (BuildContext context) => FilterModalSendername());
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showKeytokenModal(BuildContext context) {
|
||||||
|
showDialog<void>(context: context, builder: (BuildContext context) => FilterModalKeytoken());
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showTimeModal(BuildContext context) {
|
||||||
|
showDialog<void>(context: context, builder: (BuildContext context) => FilterModalTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showPlainSearchModal(BuildContext context) {
|
||||||
|
//TODO showDialog<void>(context: context, builder: (BuildContext context) => FilterModalSearchPlain());
|
||||||
|
}
|
||||||
|
}
|
@@ -1,21 +1,19 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
import 'package:simplecloudnotifier/state/app_bar_state.dart';
|
|
||||||
|
|
||||||
class AppBarProgressIndicator extends StatelessWidget implements PreferredSizeWidget {
|
class AppBarProgressIndicator extends StatelessWidget implements PreferredSizeWidget {
|
||||||
|
AppBarProgressIndicator({required this.show});
|
||||||
|
|
||||||
|
final bool show;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Size get preferredSize => Size(double.infinity, 1.0);
|
Size get preferredSize => Size(double.infinity, 1.0);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Consumer<AppBarState>(
|
if (show) {
|
||||||
builder: (context, value, child) {
|
return LinearProgressIndicator(value: null);
|
||||||
if (value.loadingIndeterminate) {
|
} else {
|
||||||
return LinearProgressIndicator(value: null);
|
return SizedBox.square(dimension: 4); // 4 height is the same as the LinearProgressIndicator
|
||||||
} else {
|
}
|
||||||
return SizedBox.square(dimension: 4); // 4 height is the same as the LinearProgressIndicator
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -7,7 +7,6 @@ class SCNScaffold extends StatelessWidget {
|
|||||||
required this.child,
|
required this.child,
|
||||||
this.title,
|
this.title,
|
||||||
this.showThemeSwitch = true,
|
this.showThemeSwitch = true,
|
||||||
this.showDebug = true,
|
|
||||||
this.showSearch = true,
|
this.showSearch = true,
|
||||||
this.showShare = false,
|
this.showShare = false,
|
||||||
this.onShare = null,
|
this.onShare = null,
|
||||||
@@ -16,7 +15,6 @@ class SCNScaffold extends StatelessWidget {
|
|||||||
final Widget child;
|
final Widget child;
|
||||||
final String? title;
|
final String? title;
|
||||||
final bool showThemeSwitch;
|
final bool showThemeSwitch;
|
||||||
final bool showDebug;
|
|
||||||
final bool showSearch;
|
final bool showSearch;
|
||||||
final bool showShare;
|
final bool showShare;
|
||||||
final void Function()? onShare;
|
final void Function()? onShare;
|
||||||
@@ -27,7 +25,6 @@ class SCNScaffold extends StatelessWidget {
|
|||||||
appBar: SCNAppBar(
|
appBar: SCNAppBar(
|
||||||
title: title,
|
title: title,
|
||||||
showThemeSwitch: showThemeSwitch,
|
showThemeSwitch: showThemeSwitch,
|
||||||
showDebug: showDebug,
|
|
||||||
showSearch: showSearch,
|
showSearch: showSearch,
|
||||||
showShare: showShare,
|
showShare: showShare,
|
||||||
onShare: onShare ?? () {},
|
onShare: onShare ?? () {},
|
||||||
|
114
flutter/lib/components/modals/filter_modal_channel.dart
Normal file
114
flutter/lib/components/modals/filter_modal_channel.dart
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:simplecloudnotifier/api/api_client.dart';
|
||||||
|
import 'package:simplecloudnotifier/models/channel.dart';
|
||||||
|
import 'package:simplecloudnotifier/pages/message_list/message_filter_chiplet.dart';
|
||||||
|
import 'package:simplecloudnotifier/state/app_auth.dart';
|
||||||
|
import 'package:simplecloudnotifier/state/app_events.dart';
|
||||||
|
import 'package:simplecloudnotifier/types/immediate_future.dart';
|
||||||
|
|
||||||
|
class FilterModalChannel extends StatefulWidget {
|
||||||
|
@override
|
||||||
|
_FilterModalChannelState createState() => _FilterModalChannelState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FilterModalChannelState extends State<FilterModalChannel> {
|
||||||
|
Set<String> _selectedEntries = {};
|
||||||
|
|
||||||
|
late ImmediateFuture<List<Channel>>? _futureChannels;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
|
||||||
|
_futureChannels = null;
|
||||||
|
_futureChannels = ImmediateFuture.ofFuture(() async {
|
||||||
|
final userAcc = Provider.of<AppAuth>(context, listen: false);
|
||||||
|
if (!userAcc.isAuth()) throw new Exception('not logged in');
|
||||||
|
|
||||||
|
final channels = await APIClient.getChannelList(userAcc, ChannelSelector.all);
|
||||||
|
|
||||||
|
return channels.where((p) => p.subscription?.confirmed ?? false).map((e) => e.channel).toList(); // return only subscribed channels
|
||||||
|
}());
|
||||||
|
}
|
||||||
|
|
||||||
|
void toggleEntry(String channelID) {
|
||||||
|
setState(() {
|
||||||
|
if (_selectedEntries.contains(channelID)) {
|
||||||
|
_selectedEntries.remove(channelID);
|
||||||
|
} else {
|
||||||
|
_selectedEntries.add(channelID);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Channels'),
|
||||||
|
content: Container(
|
||||||
|
width: 9000,
|
||||||
|
height: 9000,
|
||||||
|
child: () {
|
||||||
|
if (_futureChannels == null) {
|
||||||
|
return Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
return FutureBuilder(
|
||||||
|
future: _futureChannels!.future,
|
||||||
|
builder: ((context, snapshot) {
|
||||||
|
if (_futureChannels?.value != null) {
|
||||||
|
return _buildList(context, _futureChannels!.value!);
|
||||||
|
} else if (snapshot.connectionState == ConnectionState.done && snapshot.hasError) {
|
||||||
|
return Text('Error: ${snapshot.error}'); //TODO better error display
|
||||||
|
} else if (snapshot.connectionState == ConnectionState.done) {
|
||||||
|
return _buildList(context, snapshot.data!);
|
||||||
|
} else {
|
||||||
|
return Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}(),
|
||||||
|
),
|
||||||
|
actions: <Widget>[
|
||||||
|
TextButton(
|
||||||
|
style: TextButton.styleFrom(textStyle: Theme.of(context).textTheme.labelLarge),
|
||||||
|
child: const Text('Apply'),
|
||||||
|
onPressed: () {
|
||||||
|
onOkay();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void onOkay() {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
|
||||||
|
final chiplets = _selectedEntries
|
||||||
|
.map((e) => MessageFilterChiplet(
|
||||||
|
label: _futureChannels?.get()?.map((e) => e as Channel?).firstWhere((p) => p?.channelID == e, orElse: () => null)?.displayName ?? '???',
|
||||||
|
value: e,
|
||||||
|
type: MessageFilterChipletType.channel,
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
AppEvents().notifyFilterListeners([MessageFilterChipletType.channel], chiplets);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildList(BuildContext context, List<Channel> list) {
|
||||||
|
return ListView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
itemBuilder: (builder, index) {
|
||||||
|
final channel = list[index];
|
||||||
|
return ListTile(
|
||||||
|
title: Text(channel.displayName),
|
||||||
|
leading: Icon(_selectedEntries.contains(channel.channelID) ? Icons.check_box : Icons.check_box_outline_blank, color: Theme.of(context).primaryColor),
|
||||||
|
onTap: () => toggleEntry(channel.channelID),
|
||||||
|
visualDensity: VisualDensity(vertical: -4),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
itemCount: list.length,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
114
flutter/lib/components/modals/filter_modal_keytoken.dart
Normal file
114
flutter/lib/components/modals/filter_modal_keytoken.dart
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:simplecloudnotifier/api/api_client.dart';
|
||||||
|
import 'package:simplecloudnotifier/models/keytoken.dart';
|
||||||
|
import 'package:simplecloudnotifier/pages/message_list/message_filter_chiplet.dart';
|
||||||
|
import 'package:simplecloudnotifier/state/app_auth.dart';
|
||||||
|
import 'package:simplecloudnotifier/state/app_events.dart';
|
||||||
|
import 'package:simplecloudnotifier/types/immediate_future.dart';
|
||||||
|
|
||||||
|
class FilterModalKeytoken extends StatefulWidget {
|
||||||
|
@override
|
||||||
|
_FilterModalKeytokenState createState() => _FilterModalKeytokenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FilterModalKeytokenState extends State<FilterModalKeytoken> {
|
||||||
|
Set<String> _selectedEntries = {};
|
||||||
|
|
||||||
|
late ImmediateFuture<List<KeyToken>>? _futureKeyTokens;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
|
||||||
|
_futureKeyTokens = null;
|
||||||
|
_futureKeyTokens = ImmediateFuture.ofFuture(() async {
|
||||||
|
final userAcc = Provider.of<AppAuth>(context, listen: false);
|
||||||
|
if (!userAcc.isAuth()) throw new Exception('not logged in');
|
||||||
|
|
||||||
|
final toks = await APIClient.getKeyTokenList(userAcc);
|
||||||
|
|
||||||
|
return toks;
|
||||||
|
}());
|
||||||
|
}
|
||||||
|
|
||||||
|
void toggleEntry(String senderID) {
|
||||||
|
setState(() {
|
||||||
|
if (_selectedEntries.contains(senderID)) {
|
||||||
|
_selectedEntries.remove(senderID);
|
||||||
|
} else {
|
||||||
|
_selectedEntries.add(senderID);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Senders'),
|
||||||
|
content: Container(
|
||||||
|
width: 9000,
|
||||||
|
height: 9000,
|
||||||
|
child: () {
|
||||||
|
if (_futureKeyTokens == null) {
|
||||||
|
return Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
return FutureBuilder(
|
||||||
|
future: _futureKeyTokens!.future,
|
||||||
|
builder: ((context, snapshot) {
|
||||||
|
if (_futureKeyTokens?.value != null) {
|
||||||
|
return _buildList(context, _futureKeyTokens!.value!);
|
||||||
|
} else if (snapshot.connectionState == ConnectionState.done && snapshot.hasError) {
|
||||||
|
return Text('Error: ${snapshot.error}'); //TODO better error display
|
||||||
|
} else if (snapshot.connectionState == ConnectionState.done) {
|
||||||
|
return _buildList(context, snapshot.data!);
|
||||||
|
} else {
|
||||||
|
return Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}(),
|
||||||
|
),
|
||||||
|
actions: <Widget>[
|
||||||
|
TextButton(
|
||||||
|
style: TextButton.styleFrom(textStyle: Theme.of(context).textTheme.labelLarge),
|
||||||
|
child: const Text('Apply'),
|
||||||
|
onPressed: () {
|
||||||
|
onOkay();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void onOkay() {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
|
||||||
|
final chiplets = _selectedEntries
|
||||||
|
.map((e) => MessageFilterChiplet(
|
||||||
|
label: _futureKeyTokens?.get()?.map((e) => e as KeyToken?).firstWhere((p) => p?.keytokenID == e, orElse: () => null)?.name ?? '???',
|
||||||
|
value: e,
|
||||||
|
type: MessageFilterChipletType.sender,
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
AppEvents().notifyFilterListeners([MessageFilterChipletType.sender], chiplets);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildList(BuildContext context, List<KeyToken> list) {
|
||||||
|
return ListView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
itemBuilder: (builder, index) {
|
||||||
|
final sender = list[index];
|
||||||
|
return ListTile(
|
||||||
|
title: Text(sender.name),
|
||||||
|
leading: Icon(_selectedEntries.contains(sender.keytokenID) ? Icons.check_box : Icons.check_box_outline_blank, color: Theme.of(context).primaryColor),
|
||||||
|
onTap: () => toggleEntry(sender.keytokenID),
|
||||||
|
visualDensity: VisualDensity(vertical: -4),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
itemCount: list.length,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
67
flutter/lib/components/modals/filter_modal_priority.dart
Normal file
67
flutter/lib/components/modals/filter_modal_priority.dart
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:simplecloudnotifier/pages/message_list/message_filter_chiplet.dart';
|
||||||
|
import 'package:simplecloudnotifier/state/app_events.dart';
|
||||||
|
|
||||||
|
class FilterModalPriority extends StatefulWidget {
|
||||||
|
@override
|
||||||
|
_FilterModalPriorityState createState() => _FilterModalPriorityState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FilterModalPriorityState extends State<FilterModalPriority> {
|
||||||
|
Set<int> _selectedEntries = {};
|
||||||
|
|
||||||
|
Map<int, (String, String)> _texts = {
|
||||||
|
0: ('Low (0)', 'Low'),
|
||||||
|
1: ('Normal (1)', 'Normal'),
|
||||||
|
2: ('High (2)', 'High'),
|
||||||
|
};
|
||||||
|
|
||||||
|
void toggleEntry(int entry) {
|
||||||
|
setState(() {
|
||||||
|
if (_selectedEntries.contains(entry)) {
|
||||||
|
_selectedEntries.remove(entry);
|
||||||
|
} else {
|
||||||
|
_selectedEntries.add(entry);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Priority'),
|
||||||
|
content: Container(
|
||||||
|
width: 0,
|
||||||
|
height: 200,
|
||||||
|
child: ListView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
itemBuilder: (builder, index) {
|
||||||
|
return ListTile(
|
||||||
|
title: Text(_texts[index]?.$1 ?? '???'),
|
||||||
|
leading: Icon(_selectedEntries.contains(index) ? Icons.check_box : Icons.check_box_outline_blank, color: Theme.of(context).primaryColor),
|
||||||
|
onTap: () => toggleEntry(index),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
itemCount: 3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: <Widget>[
|
||||||
|
TextButton(
|
||||||
|
style: TextButton.styleFrom(textStyle: Theme.of(context).textTheme.labelLarge),
|
||||||
|
child: const Text('Apply'),
|
||||||
|
onPressed: () {
|
||||||
|
onOkay();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void onOkay() {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
|
||||||
|
final chiplets = _selectedEntries.map((e) => MessageFilterChiplet(label: _texts[e]?.$2 ?? '???', value: e, type: MessageFilterChipletType.priority)).toList();
|
||||||
|
|
||||||
|
AppEvents().notifyFilterListeners([MessageFilterChipletType.priority], chiplets);
|
||||||
|
}
|
||||||
|
}
|
113
flutter/lib/components/modals/filter_modal_sendername.dart
Normal file
113
flutter/lib/components/modals/filter_modal_sendername.dart
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:simplecloudnotifier/api/api_client.dart';
|
||||||
|
import 'package:simplecloudnotifier/pages/message_list/message_filter_chiplet.dart';
|
||||||
|
import 'package:simplecloudnotifier/state/app_auth.dart';
|
||||||
|
import 'package:simplecloudnotifier/state/app_events.dart';
|
||||||
|
import 'package:simplecloudnotifier/types/immediate_future.dart';
|
||||||
|
|
||||||
|
class FilterModalSendername extends StatefulWidget {
|
||||||
|
@override
|
||||||
|
_FilterModalSendernameState createState() => _FilterModalSendernameState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FilterModalSendernameState extends State<FilterModalSendername> {
|
||||||
|
Set<String> _selectedEntries = {};
|
||||||
|
|
||||||
|
late ImmediateFuture<List<String>>? _futureSenders;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
|
||||||
|
_futureSenders = null;
|
||||||
|
_futureSenders = ImmediateFuture.ofFuture(() async {
|
||||||
|
final userAcc = Provider.of<AppAuth>(context, listen: false);
|
||||||
|
if (!userAcc.isAuth()) throw new Exception('not logged in');
|
||||||
|
|
||||||
|
final senders = (await APIClient.getSenderNameList(userAcc)).map((p) => p.name).toList();
|
||||||
|
|
||||||
|
return senders;
|
||||||
|
}());
|
||||||
|
}
|
||||||
|
|
||||||
|
void toggleEntry(String senderID) {
|
||||||
|
setState(() {
|
||||||
|
if (_selectedEntries.contains(senderID)) {
|
||||||
|
_selectedEntries.remove(senderID);
|
||||||
|
} else {
|
||||||
|
_selectedEntries.add(senderID);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Senders'),
|
||||||
|
content: Container(
|
||||||
|
width: 9000,
|
||||||
|
height: 9000,
|
||||||
|
child: () {
|
||||||
|
if (_futureSenders == null) {
|
||||||
|
return Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
return FutureBuilder(
|
||||||
|
future: _futureSenders!.future,
|
||||||
|
builder: ((context, snapshot) {
|
||||||
|
if (_futureSenders?.value != null) {
|
||||||
|
return _buildList(context, _futureSenders!.value!);
|
||||||
|
} else if (snapshot.connectionState == ConnectionState.done && snapshot.hasError) {
|
||||||
|
return Text('Error: ${snapshot.error}'); //TODO better error display
|
||||||
|
} else if (snapshot.connectionState == ConnectionState.done) {
|
||||||
|
return _buildList(context, snapshot.data!);
|
||||||
|
} else {
|
||||||
|
return Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}(),
|
||||||
|
),
|
||||||
|
actions: <Widget>[
|
||||||
|
TextButton(
|
||||||
|
style: TextButton.styleFrom(textStyle: Theme.of(context).textTheme.labelLarge),
|
||||||
|
child: const Text('Apply'),
|
||||||
|
onPressed: () {
|
||||||
|
onOkay();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void onOkay() {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
|
||||||
|
final chiplets = _selectedEntries
|
||||||
|
.map((e) => MessageFilterChiplet(
|
||||||
|
label: e,
|
||||||
|
value: e,
|
||||||
|
type: MessageFilterChipletType.sender,
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
AppEvents().notifyFilterListeners([MessageFilterChipletType.sender], chiplets);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildList(BuildContext context, List<String> list) {
|
||||||
|
return ListView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
itemBuilder: (builder, index) {
|
||||||
|
final sender = list[index];
|
||||||
|
return ListTile(
|
||||||
|
title: Text(sender),
|
||||||
|
leading: Icon(_selectedEntries.contains(sender) ? Icons.check_box : Icons.check_box_outline_blank, color: Theme.of(context).primaryColor),
|
||||||
|
onTap: () => toggleEntry(sender),
|
||||||
|
visualDensity: VisualDensity(vertical: -4),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
itemCount: list.length,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
43
flutter/lib/components/modals/filter_modal_time.dart
Normal file
43
flutter/lib/components/modals/filter_modal_time.dart
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class FilterModalTime extends StatefulWidget {
|
||||||
|
@override
|
||||||
|
_FilterModalTimeState createState() => _FilterModalTimeState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FilterModalTimeState extends State<FilterModalTime> {
|
||||||
|
DateTime? _tsBefore = null;
|
||||||
|
DateTime? _tsAfter = null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Timerange'),
|
||||||
|
content: Container(
|
||||||
|
width: 9000,
|
||||||
|
height: 9000,
|
||||||
|
child: Placeholder(),
|
||||||
|
),
|
||||||
|
actions: <Widget>[
|
||||||
|
TextButton(
|
||||||
|
style: TextButton.styleFrom(textStyle: Theme.of(context).textTheme.labelLarge),
|
||||||
|
child: const Text('Apply'),
|
||||||
|
onPressed: () {
|
||||||
|
onOkay();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void onOkay() {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
|
||||||
|
//TODO
|
||||||
|
}
|
||||||
|
}
|
@@ -2,14 +2,20 @@ import 'dart:io';
|
|||||||
|
|
||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/scheduler.dart';
|
||||||
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:hive_flutter/hive_flutter.dart';
|
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/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/message_view/message_view.dart';
|
||||||
|
import 'package:simplecloudnotifier/settings/app_settings.dart';
|
||||||
import 'package:simplecloudnotifier/state/app_bar_state.dart';
|
import 'package:simplecloudnotifier/state/app_bar_state.dart';
|
||||||
|
import 'package:simplecloudnotifier/state/app_events.dart';
|
||||||
import 'package:simplecloudnotifier/state/app_theme.dart';
|
import 'package:simplecloudnotifier/state/app_theme.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';
|
||||||
@@ -17,7 +23,9 @@ import 'package:simplecloudnotifier/state/globals.dart';
|
|||||||
import 'package:simplecloudnotifier/state/request_log.dart';
|
import 'package:simplecloudnotifier/state/request_log.dart';
|
||||||
import 'package:simplecloudnotifier/state/app_auth.dart';
|
import 'package:simplecloudnotifier/state/app_auth.dart';
|
||||||
import 'package:firebase_core/firebase_core.dart';
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
|
import 'package:simplecloudnotifier/state/scn_data_cache.dart';
|
||||||
import 'package:simplecloudnotifier/utils/navi.dart';
|
import 'package:simplecloudnotifier/utils/navi.dart';
|
||||||
|
import 'package:simplecloudnotifier/utils/notifier.dart';
|
||||||
import 'package:toastification/toastification.dart';
|
import 'package:toastification/toastification.dart';
|
||||||
import 'firebase_options.dart';
|
import 'firebase_options.dart';
|
||||||
|
|
||||||
@@ -39,20 +47,10 @@ void main() async {
|
|||||||
Hive.registerAdapter(SCNRequestAdapter());
|
Hive.registerAdapter(SCNRequestAdapter());
|
||||||
Hive.registerAdapter(SCNLogAdapter());
|
Hive.registerAdapter(SCNLogAdapter());
|
||||||
Hive.registerAdapter(SCNLogLevelAdapter());
|
Hive.registerAdapter(SCNLogLevelAdapter());
|
||||||
Hive.registerAdapter(MessageAdapter());
|
Hive.registerAdapter(SCNMessageAdapter());
|
||||||
Hive.registerAdapter(ChannelAdapter());
|
Hive.registerAdapter(ChannelAdapter());
|
||||||
Hive.registerAdapter(FBMessageAdapter());
|
Hive.registerAdapter(FBMessageAdapter());
|
||||||
|
|
||||||
print('[INIT] Load Hive<scn-requests>...');
|
|
||||||
|
|
||||||
try {
|
|
||||||
await Hive.openBox<SCNRequest>('scn-requests');
|
|
||||||
} catch (exc, trace) {
|
|
||||||
Hive.deleteBoxFromDisk('scn-requests');
|
|
||||||
await Hive.openBox<SCNRequest>('scn-requests');
|
|
||||||
ApplicationLog.error('Failed to open Hive-Box: scn-requests: ' + exc.toString(), trace: trace);
|
|
||||||
}
|
|
||||||
|
|
||||||
print('[INIT] Load Hive<scn-logs>...');
|
print('[INIT] Load Hive<scn-logs>...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -61,16 +59,29 @@ 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>...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Hive.openBox<SCNRequest>('scn-requests');
|
||||||
|
} catch (exc, trace) {
|
||||||
|
Hive.deleteBoxFromDisk('scn-requests');
|
||||||
|
await Hive.openBox<SCNRequest>('scn-requests');
|
||||||
|
ApplicationLog.error('Failed to open Hive-Box: scn-requests: ' + exc.toString(), trace: trace);
|
||||||
|
ApplicationLog.writeRawFailure('Failed to open Hive-Box: scn-requests', {'error': exc.toString(), 'trace': trace});
|
||||||
}
|
}
|
||||||
|
|
||||||
print('[INIT] Load Hive<scn-message-cache>...');
|
print('[INIT] Load Hive<scn-message-cache>...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Hive.openBox<Message>('scn-message-cache');
|
await Hive.openBox<SCNMessage>('scn-message-cache');
|
||||||
} catch (exc, trace) {
|
} catch (exc, trace) {
|
||||||
Hive.deleteBoxFromDisk('scn-message-cache');
|
Hive.deleteBoxFromDisk('scn-message-cache');
|
||||||
await Hive.openBox<Message>('scn-message-cache');
|
await Hive.openBox<SCNMessage>('scn-message-cache');
|
||||||
ApplicationLog.error('Failed to open Hive-Box: scn-message-cache' + exc.toString(), trace: trace);
|
ApplicationLog.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>...');
|
||||||
@@ -81,6 +92,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>...');
|
||||||
@@ -91,6 +103,7 @@ 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 AppAuth...');
|
print('[INIT] Load AppAuth...');
|
||||||
@@ -98,20 +111,21 @@ void main() async {
|
|||||||
final appAuth = AppAuth(); // ensure UserAccount is loaded
|
final appAuth = AppAuth(); // ensure UserAccount is loaded
|
||||||
|
|
||||||
if (appAuth.isAuth()) {
|
if (appAuth.isAuth()) {
|
||||||
try {
|
// load user+client in background
|
||||||
print('[INIT] Load User...');
|
() async {
|
||||||
await appAuth.loadUser();
|
try {
|
||||||
//TODO fallback to cached user (perhaps event use cached client (if exists) directly and only update/load in background)
|
await appAuth.loadUser();
|
||||||
} catch (exc, trace) {
|
} catch (exc, trace) {
|
||||||
ApplicationLog.error('Failed to load user (on startup): ' + exc.toString(), trace: trace);
|
ApplicationLog.error('Failed to load user (background load on startup): ' + exc.toString(), trace: trace);
|
||||||
}
|
ApplicationLog.writeRawFailure('Failed to load user (background load on startup)', {'error': exc.toString(), 'trace': trace});
|
||||||
try {
|
}
|
||||||
print('[INIT] Load Client...');
|
try {
|
||||||
await appAuth.loadClient();
|
await appAuth.loadClient();
|
||||||
//TODO fallback to cached client (perhaps event use cached client (if exists) directly and only update/load in background)
|
} catch (exc, trace) {
|
||||||
} catch (exc, trace) {
|
ApplicationLog.error('Failed to load user (background load on startup): ' + exc.toString(), trace: trace);
|
||||||
ApplicationLog.error('Failed to load user (on startup): ' + exc.toString(), trace: trace);
|
ApplicationLog.writeRawFailure('Failed to load user (background load on startup)', {'error': exc.toString(), 'trace': trace});
|
||||||
}
|
}
|
||||||
|
}();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Platform.isLinux) {
|
if (!Platform.isLinux) {
|
||||||
@@ -147,6 +161,46 @@ void main() async {
|
|||||||
print('[INIT] Skip Firebase init (Platform == Linux)...');
|
print('[INIT] Skip Firebase init (Platform == Linux)...');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
print('[INIT] Load Notifications...');
|
||||||
|
|
||||||
|
final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();
|
||||||
|
final flutterLocalNotificationsPluginImpl = flutterLocalNotificationsPlugin.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>();
|
||||||
|
if (flutterLocalNotificationsPluginImpl == null) {
|
||||||
|
ApplicationLog.error('Failed to get AndroidFlutterLocalNotificationsPlugin', trace: StackTrace.current);
|
||||||
|
} else {
|
||||||
|
flutterLocalNotificationsPluginImpl.requestNotificationsPermission();
|
||||||
|
|
||||||
|
final initializationSettingsAndroid = AndroidInitializationSettings('ic_notification_white');
|
||||||
|
final initializationSettingsDarwin = DarwinInitializationSettings(
|
||||||
|
requestAlertPermission: true,
|
||||||
|
requestBadgePermission: true,
|
||||||
|
requestSoundPermission: true,
|
||||||
|
onDidReceiveLocalNotification: _receiveLocalDarwinNotification,
|
||||||
|
notificationCategories: getDarwinNotificationCategories(),
|
||||||
|
);
|
||||||
|
final initializationSettingsLinux = LinuxInitializationSettings(defaultActionName: 'Open notification');
|
||||||
|
final initializationSettings = InitializationSettings(
|
||||||
|
android: initializationSettingsAndroid,
|
||||||
|
iOS: initializationSettingsDarwin,
|
||||||
|
linux: initializationSettingsLinux,
|
||||||
|
);
|
||||||
|
flutterLocalNotificationsPlugin.initialize(
|
||||||
|
initializationSettings,
|
||||||
|
onDidReceiveNotificationResponse: _receiveLocalNotification,
|
||||||
|
onDidReceiveBackgroundNotificationResponse: _notificationTapBackground,
|
||||||
|
);
|
||||||
|
|
||||||
|
final appLaunchNotification = await flutterLocalNotificationsPlugin.getNotificationAppLaunchDetails();
|
||||||
|
if (appLaunchNotification != null) {
|
||||||
|
// Use has launched SCN by clicking on a loca notifiaction, if it was a summary or message notifiaction open the corresponding screen
|
||||||
|
// This is android only
|
||||||
|
//TODO same on iOS, somehow??
|
||||||
|
ApplicationLog.info('App launched by notification: ${appLaunchNotification.notificationResponse?.id}');
|
||||||
|
|
||||||
|
_handleNotificationClickAction(appLaunchNotification.notificationResponse?.payload, Duration(milliseconds: 600));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ApplicationLog.debug('[INIT] Application started');
|
ApplicationLog.debug('[INIT] Application started');
|
||||||
|
|
||||||
runApp(
|
runApp(
|
||||||
@@ -155,6 +209,7 @@ void main() async {
|
|||||||
ChangeNotifierProvider(create: (context) => AppAuth(), lazy: false),
|
ChangeNotifierProvider(create: (context) => AppAuth(), lazy: false),
|
||||||
ChangeNotifierProvider(create: (context) => AppTheme(), lazy: false),
|
ChangeNotifierProvider(create: (context) => AppTheme(), lazy: false),
|
||||||
ChangeNotifierProvider(create: (context) => AppBarState(), lazy: false),
|
ChangeNotifierProvider(create: (context) => AppBarState(), lazy: false),
|
||||||
|
ChangeNotifierProvider(create: (context) => AppSettings(), lazy: false),
|
||||||
],
|
],
|
||||||
child: SCNApp(),
|
child: SCNApp(),
|
||||||
),
|
),
|
||||||
@@ -164,16 +219,19 @@ void main() async {
|
|||||||
class SCNApp extends StatelessWidget {
|
class SCNApp extends StatelessWidget {
|
||||||
SCNApp({super.key});
|
SCNApp({super.key});
|
||||||
|
|
||||||
|
static var materialKey = GlobalKey<NavigatorState>();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
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>(
|
||||||
builder: (context, appTheme, child) => MaterialApp(
|
builder: (context, appTheme, child) => MaterialApp(
|
||||||
|
navigatorKey: SCNApp.materialKey,
|
||||||
title: 'SimpleCloudNotifier',
|
title: 'SimpleCloudNotifier',
|
||||||
navigatorObservers: [Navi.routeObserver, Navi.modalRouteObserver],
|
navigatorObservers: [Navi.routeObserver, Navi.modalRouteObserver],
|
||||||
theme: ThemeData(
|
theme: ThemeData(
|
||||||
@@ -188,6 +246,12 @@ class SCNApp extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@pragma('vm:entry-point')
|
||||||
|
void _notificationTapBackground(NotificationResponse notificationResponse) {
|
||||||
|
// I think only iOS triggers this, TODO
|
||||||
|
ApplicationLog.info('Received local notification<vm:entry-point>: ${notificationResponse.id}');
|
||||||
|
}
|
||||||
|
|
||||||
void setFirebaseToken(String fcmToken) async {
|
void setFirebaseToken(String fcmToken) async {
|
||||||
final acc = AppAuth();
|
final acc = AppAuth();
|
||||||
|
|
||||||
@@ -224,18 +288,133 @@ void setFirebaseToken(String fcmToken) async {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@pragma('vm:entry-point')
|
||||||
Future<void> _onBackgroundMessage(RemoteMessage message) async {
|
Future<void> _onBackgroundMessage(RemoteMessage message) async {
|
||||||
|
// a firebase message was received while the app was in the background or terminated
|
||||||
await _receiveMessage(message, false);
|
await _receiveMessage(message, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@pragma('vm:entry-point')
|
||||||
void _onForegroundMessage(RemoteMessage message) {
|
void _onForegroundMessage(RemoteMessage message) {
|
||||||
|
// a firebase message was received while the app was in the foreground
|
||||||
_receiveMessage(message, true);
|
_receiveMessage(message, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _receiveMessage(RemoteMessage message, bool foreground) async {
|
Future<void> _receiveMessage(RemoteMessage message, bool foreground) async {
|
||||||
// ensure init
|
try {
|
||||||
Hive.openBox<SCNLog>('scn-logs');
|
// ensure globals init
|
||||||
|
if (!Globals().isInitialized) {
|
||||||
|
print('[LATE-INIT] Init Globals() - to ensure working _receiveMessage($foreground)...');
|
||||||
|
await Globals().init();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensure hive init
|
||||||
|
if (!Hive.isBoxOpen('scn-logs')) {
|
||||||
|
print('[LATE-INIT] Init Hive - to ensure working _receiveMessage($foreground)...');
|
||||||
|
|
||||||
|
await Hive.initFlutter();
|
||||||
|
Hive.registerAdapter(SCNRequestAdapter());
|
||||||
|
Hive.registerAdapter(SCNLogAdapter());
|
||||||
|
Hive.registerAdapter(SCNLogLevelAdapter());
|
||||||
|
Hive.registerAdapter(SCNMessageAdapter());
|
||||||
|
Hive.registerAdapter(ChannelAdapter());
|
||||||
|
Hive.registerAdapter(FBMessageAdapter());
|
||||||
|
}
|
||||||
|
|
||||||
|
print('[LATE-INIT] Ensure hive boxes are open for _receiveMessage($foreground)...');
|
||||||
|
|
||||||
|
await Hive.openBox<SCNLog>('scn-logs');
|
||||||
|
await Hive.openBox<FBMessage>('scn-fb-messages');
|
||||||
|
await Hive.openBox<SCNMessage>('scn-message-cache');
|
||||||
|
await Hive.openBox<SCNRequest>('scn-requests');
|
||||||
|
} catch (exc, trace) {
|
||||||
|
ApplicationLog.writeRawFailure('Failed to init hive', {'exception': exc.toString(), 'trace': trace.toString(), 'data': message, 'foreground': foreground});
|
||||||
|
ApplicationLog.error('Failed to init hive:' + exc.toString(), trace: trace);
|
||||||
|
Notifier.showLocalNotification("", "@ERROR", "@ERROR", 'Error Channel', "Error", "Failed to receive SCN message (init failed)", null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
ApplicationLog.info('Received FB message (${foreground ? 'foreground' : 'background'}): ${message.messageId ?? 'NULL'}');
|
ApplicationLog.info('Received FB message (${foreground ? 'foreground' : 'background'}): ${message.messageId ?? 'NULL'}');
|
||||||
FBMessageLog.insert(message);
|
|
||||||
|
String scn_msg_id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
scn_msg_id = message.data['scn_msg_id'] as String;
|
||||||
|
|
||||||
|
final timestamp = DateTime.fromMillisecondsSinceEpoch(int.parse(message.data['timestamp'] as String) * 1000);
|
||||||
|
final title = message.data['title'] as String;
|
||||||
|
final channel = message.data['channel'] as String;
|
||||||
|
final channel_id = message.data['channel_id'] as String;
|
||||||
|
final body = message.data['body'] as String;
|
||||||
|
|
||||||
|
Notifier.showLocalNotification(scn_msg_id, channel_id, channel, 'Channel: ${channel}', title, body, timestamp);
|
||||||
|
} catch (exc, trace) {
|
||||||
|
ApplicationLog.writeRawFailure('Failed to decode received FB message', {'exception': exc.toString(), 'trace': trace.toString(), 'data': message, 'foreground': foreground});
|
||||||
|
ApplicationLog.error('Failed to decode received FB message' + exc.toString(), trace: trace);
|
||||||
|
Notifier.showLocalNotification("", "@ERROR", "@ERROR", 'Error Channel', "Error", "Failed to receive SCN message (decode failed)", null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
FBMessageLog.insert(message);
|
||||||
|
} catch (exc, trace) {
|
||||||
|
ApplicationLog.writeRawFailure('Failed to persist received FB message', {'exception': exc.toString(), 'trace': trace.toString(), 'data': message, 'foreground': foreground});
|
||||||
|
ApplicationLog.error('Failed to persist received FB message' + exc.toString(), trace: trace);
|
||||||
|
Notifier.showLocalNotification("", "@ERROR", "@ERROR", 'Error Channel', "Error", "Failed to receive SCN message (persist failed)", null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final msg = await APIClient.getMessage(AppAuth(), scn_msg_id);
|
||||||
|
SCNDataCache().addToMessageCache([msg]);
|
||||||
|
if (foreground) AppEvents().notifyMessageReceivedListeners(msg);
|
||||||
|
} catch (exc, trace) {
|
||||||
|
ApplicationLog.writeRawFailure('Failed to query+persist message', {'exception': exc.toString(), 'trace': trace.toString(), 'data': message, 'foreground': foreground});
|
||||||
|
ApplicationLog.error('Failed to query+persist message: ' + exc.toString(), trace: trace);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _receiveLocalDarwinNotification(int id, String? title, String? body, String? payload) {
|
||||||
|
//TODO iOS?
|
||||||
|
ApplicationLog.info('Received local notification<darwin>: $id -> [$title]');
|
||||||
|
}
|
||||||
|
|
||||||
|
void _receiveLocalNotification(NotificationResponse details) {
|
||||||
|
// User has tapped a flutter_local notification, while the app was running
|
||||||
|
ApplicationLog.info('Tapped local notification: [[${details.id} | ${details.actionId} | ${details.input} | ${details.notificationResponseType} | ${details.payload}]]');
|
||||||
|
|
||||||
|
_handleNotificationClickAction(details.payload, Duration.zero);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleNotificationClickAction(String? payload, Duration delay) {
|
||||||
|
final parts = payload?.split('\n') ?? [];
|
||||||
|
|
||||||
|
if (parts.length == 4 && parts[0] == '@SCN_MESSAGE') {
|
||||||
|
final messageID = parts[1];
|
||||||
|
() async {
|
||||||
|
await Future.delayed(delay, () {});
|
||||||
|
|
||||||
|
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||||
|
ApplicationLog.info('Handle notification action @SCN_MESSAGE --> ${messageID}');
|
||||||
|
Navi.push(SCNApp.materialKey.currentContext!, () => MessageViewPage(messageID: messageID, preloadedData: null));
|
||||||
|
});
|
||||||
|
}();
|
||||||
|
} else if (parts.length == 3 && parts[0] == '@SCN_MESSAGE_SUMMARY') {
|
||||||
|
final channelID = parts[1];
|
||||||
|
() async {
|
||||||
|
await Future.delayed(delay, () {});
|
||||||
|
|
||||||
|
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||||
|
ApplicationLog.info('Handle notification action @SCN_MESSAGE_SUMMARY --> ${channelID}');
|
||||||
|
Navi.push(SCNApp.materialKey.currentContext!, () => ChannelViewPage(channelID: channelID, preloadedData: null, needsReload: null));
|
||||||
|
});
|
||||||
|
}();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<DarwinNotificationCategory> getDarwinNotificationCategories() {
|
||||||
|
return <DarwinNotificationCategory>[
|
||||||
|
//TODO ?!?
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
@@ -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;
|
||||||
@@ -66,8 +66,8 @@ class APIError {
|
|||||||
factory APIError.fromJson(Map<String, dynamic> json) {
|
factory APIError.fromJson(Map<String, dynamic> json) {
|
||||||
return APIError(
|
return APIError(
|
||||||
success: json['success'] as bool,
|
success: json['success'] as bool,
|
||||||
error: (json['error'] as double).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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -12,11 +12,11 @@ class Channel extends HiveObject implements FieldDebuggable {
|
|||||||
@HiveField(10)
|
@HiveField(10)
|
||||||
final String ownerUserID;
|
final String ownerUserID;
|
||||||
@HiveField(11)
|
@HiveField(11)
|
||||||
final String internalName;
|
final String internalName; // = InternalName, used for sending, normalized, cannot be changed
|
||||||
@HiveField(12)
|
@HiveField(12)
|
||||||
final String displayName;
|
final String displayName; // = DisplayName, used for display purposes, can be changed, initially equals InternalName
|
||||||
@HiveField(13)
|
@HiveField(13)
|
||||||
final String? descriptionName;
|
final String? descriptionName; // = DescriptionName, (optional), longer description text, initally nil
|
||||||
@HiveField(14)
|
@HiveField(14)
|
||||||
final String? subscribeKey;
|
final String? subscribeKey;
|
||||||
@HiveField(15)
|
@HiveField(15)
|
||||||
@@ -70,11 +70,21 @@ class Channel extends HiveObject implements FieldDebuggable {
|
|||||||
('messagesSent', '${this.messagesSent}'),
|
('messagesSent', '${this.messagesSent}'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ChannelPreview toPreview() {
|
||||||
|
return ChannelPreview(
|
||||||
|
channelID: this.channelID,
|
||||||
|
ownerUserID: this.ownerUserID,
|
||||||
|
internalName: this.internalName,
|
||||||
|
displayName: this.displayName,
|
||||||
|
descriptionName: this.descriptionName,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class ChannelWithSubscription {
|
class ChannelWithSubscription {
|
||||||
final Channel channel;
|
final Channel channel;
|
||||||
final Subscription subscription;
|
final Subscription? subscription;
|
||||||
|
|
||||||
ChannelWithSubscription({
|
ChannelWithSubscription({
|
||||||
required this.channel,
|
required this.channel,
|
||||||
@@ -84,7 +94,7 @@ class ChannelWithSubscription {
|
|||||||
factory ChannelWithSubscription.fromJson(Map<String, dynamic> json) {
|
factory ChannelWithSubscription.fromJson(Map<String, dynamic> json) {
|
||||||
return ChannelWithSubscription(
|
return ChannelWithSubscription(
|
||||||
channel: Channel.fromJson(json),
|
channel: Channel.fromJson(json),
|
||||||
subscription: Subscription.fromJson(json['subscription'] as Map<String, dynamic>),
|
subscription: json['subscription'] == null ? null : Subscription.fromJson(json['subscription'] as Map<String, dynamic>),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -32,6 +32,19 @@ class Client {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'client_id': clientID,
|
||||||
|
'user_id': userID,
|
||||||
|
'type': type,
|
||||||
|
'fcm_token': fcmToken,
|
||||||
|
'timestamp_created': timestampCreated,
|
||||||
|
'agent_model': agentModel,
|
||||||
|
'agent_version': agentVersion,
|
||||||
|
'name': name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
static List<Client> fromJsonArray(List<dynamic> jsonArr) {
|
static List<Client> fromJsonArray(List<dynamic> jsonArr) {
|
||||||
return jsonArr.map<Client>((e) => Client.fromJson(e as Map<String, dynamic>)).toList();
|
return jsonArr.map<Client>((e) => Client.fromJson(e as Map<String, dynamic>)).toList();
|
||||||
}
|
}
|
||||||
|
75
flutter/lib/models/scan_result.dart
Normal file
75
flutter/lib/models/scan_result.dart
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import 'package:simplecloudnotifier/models/channel.dart';
|
||||||
|
|
||||||
|
enum ScanResultMode { ChannelSubscribe, MessageSend, Channel }
|
||||||
|
|
||||||
|
abstract class ScanResult {
|
||||||
|
ScanResultMode get mode;
|
||||||
|
|
||||||
|
static ScanResult? parse(String v) {
|
||||||
|
var lines = v.split('\n');
|
||||||
|
|
||||||
|
if (lines.length == 1 && lines[0].startsWith('https://simplecloudnotifier.de?')) {
|
||||||
|
final v = Uri.tryParse(lines[0]);
|
||||||
|
|
||||||
|
if (v != null && v.queryParameters.containsKey('preset_user_id') && v.queryParameters.containsKey('preset_user_key')) {
|
||||||
|
return ScanResultMessageSend(userID: v.queryParameters['preset_user_id']!, userKey: v.queryParameters['preset_user_key']);
|
||||||
|
}
|
||||||
|
if (v != null && v.queryParameters.containsKey('preset_user_id') && v.queryParameters.containsKey('preset_user_key')) {
|
||||||
|
return ScanResultMessageSend(userID: v.queryParameters['preset_user_id']!, userKey: null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lines.length == 6 && lines[0] == '@scn.channel.subscribe' && lines[1] == 'v1') {
|
||||||
|
return ScanResultChannelSubscribe(channelDisplayName: lines[2], ownerUserID: lines[3], channelID: lines[4], subscribeKey: lines[5]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lines.length == 5 && lines[0] == '@scn.channel' && lines[1] == 'v1') {
|
||||||
|
if (lines.length != 4) return null;
|
||||||
|
|
||||||
|
return ScanResultChannel(channelDisplayName: lines[2], ownerUserID: lines[3], channelID: lines[4]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String createChannelQR(Channel channel) {
|
||||||
|
return '@scn.channel' + '\n' + "v1" + '\n' + channel.displayName + '\n' + channel.ownerUserID + '\n' + channel.channelID;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String createChannelSubscribeQR(Channel channel, String subscribeKey) {
|
||||||
|
return '@scn.channel.subscribe' + '\n' + "v1" + '\n' + channel.displayName + '\n' + channel.ownerUserID + '\n' + channel.channelID + '\n' + subscribeKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ScanResultMessageSend extends ScanResult {
|
||||||
|
final String userID;
|
||||||
|
final String? userKey;
|
||||||
|
|
||||||
|
ScanResultMessageSend({required this.userID, required this.userKey});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ScanResultMode get mode => ScanResultMode.MessageSend;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ScanResultChannel extends ScanResult {
|
||||||
|
final String channelDisplayName;
|
||||||
|
final String ownerUserID;
|
||||||
|
final String channelID;
|
||||||
|
|
||||||
|
ScanResultChannel({required this.channelDisplayName, required this.ownerUserID, required this.channelID});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ScanResultMode get mode => ScanResultMode.Channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ScanResultChannelSubscribe extends ScanResult {
|
||||||
|
final String channelDisplayName;
|
||||||
|
final String ownerUserID;
|
||||||
|
final String channelID;
|
||||||
|
final String subscribeKey;
|
||||||
|
|
||||||
|
ScanResultChannelSubscribe({required this.channelDisplayName, required this.ownerUserID, required this.channelID, required this.subscribeKey});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ScanResultMode get mode => ScanResultMode.ChannelSubscribe;
|
||||||
|
}
|
@@ -1,10 +1,10 @@
|
|||||||
import 'package:hive_flutter/hive_flutter.dart';
|
import 'package:hive_flutter/hive_flutter.dart';
|
||||||
import 'package:simplecloudnotifier/state/interfaces.dart';
|
import 'package:simplecloudnotifier/state/interfaces.dart';
|
||||||
|
|
||||||
part 'message.g.dart';
|
part 'scn_message.g.dart';
|
||||||
|
|
||||||
@HiveType(typeId: 105)
|
@HiveType(typeId: 105)
|
||||||
class Message extends HiveObject implements FieldDebuggable {
|
class SCNMessage extends HiveObject implements FieldDebuggable {
|
||||||
@HiveField(0)
|
@HiveField(0)
|
||||||
final String messageID;
|
final String messageID;
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ class Message extends HiveObject implements FieldDebuggable {
|
|||||||
@HiveField(21)
|
@HiveField(21)
|
||||||
final bool trimmed;
|
final bool trimmed;
|
||||||
|
|
||||||
Message({
|
SCNMessage({
|
||||||
required this.messageID,
|
required this.messageID,
|
||||||
required this.senderUserID,
|
required this.senderUserID,
|
||||||
required this.channelInternalName,
|
required this.channelInternalName,
|
||||||
@@ -49,8 +49,8 @@ class Message extends HiveObject implements FieldDebuggable {
|
|||||||
required this.trimmed,
|
required this.trimmed,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory Message.fromJson(Map<String, dynamic> json) {
|
factory SCNMessage.fromJson(Map<String, dynamic> json) {
|
||||||
return Message(
|
return SCNMessage(
|
||||||
messageID: json['message_id'] as String,
|
messageID: json['message_id'] as String,
|
||||||
senderUserID: json['sender_user_id'] as String,
|
senderUserID: json['sender_user_id'] as String,
|
||||||
channelInternalName: json['channel_internal_name'] as String,
|
channelInternalName: json['channel_internal_name'] as String,
|
||||||
@@ -67,10 +67,10 @@ class Message extends HiveObject implements FieldDebuggable {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static (String, List<Message>) fromPaginatedJsonArray(Map<String, dynamic> data, String keyMessages, String keyToken) {
|
static (String, List<SCNMessage>) fromPaginatedJsonArray(Map<String, dynamic> data, String keyMessages, String keyToken) {
|
||||||
final npt = data[keyToken] as String;
|
final npt = data[keyToken] as String;
|
||||||
|
|
||||||
final messages = (data[keyMessages] as List<dynamic>).map<Message>((e) => Message.fromJson(e as Map<String, dynamic>)).toList();
|
final messages = (data[keyMessages] as List<dynamic>).map<SCNMessage>((e) => SCNMessage.fromJson(e as Map<String, dynamic>)).toList();
|
||||||
|
|
||||||
return (npt, messages);
|
return (npt, messages);
|
||||||
}
|
}
|
@@ -1,22 +1,22 @@
|
|||||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
part of 'message.dart';
|
part of 'scn_message.dart';
|
||||||
|
|
||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
// TypeAdapterGenerator
|
// TypeAdapterGenerator
|
||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
class MessageAdapter extends TypeAdapter<Message> {
|
class SCNMessageAdapter extends TypeAdapter<SCNMessage> {
|
||||||
@override
|
@override
|
||||||
final int typeId = 105;
|
final int typeId = 105;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Message read(BinaryReader reader) {
|
SCNMessage read(BinaryReader reader) {
|
||||||
final numOfFields = reader.readByte();
|
final numOfFields = reader.readByte();
|
||||||
final fields = <int, dynamic>{
|
final fields = <int, dynamic>{
|
||||||
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||||
};
|
};
|
||||||
return Message(
|
return SCNMessage(
|
||||||
messageID: fields[0] as String,
|
messageID: fields[0] as String,
|
||||||
senderUserID: fields[10] as String,
|
senderUserID: fields[10] as String,
|
||||||
channelInternalName: fields[11] as String,
|
channelInternalName: fields[11] as String,
|
||||||
@@ -34,7 +34,7 @@ class MessageAdapter extends TypeAdapter<Message> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void write(BinaryWriter writer, Message obj) {
|
void write(BinaryWriter writer, SCNMessage obj) {
|
||||||
writer
|
writer
|
||||||
..writeByte(13)
|
..writeByte(13)
|
||||||
..writeByte(0)
|
..writeByte(0)
|
||||||
@@ -71,7 +71,7 @@ class MessageAdapter extends TypeAdapter<Message> {
|
|||||||
@override
|
@override
|
||||||
bool operator ==(Object other) =>
|
bool operator ==(Object other) =>
|
||||||
identical(this, other) ||
|
identical(this, other) ||
|
||||||
other is MessageAdapter &&
|
other is SCNMessageAdapter &&
|
||||||
runtimeType == other.runtimeType &&
|
runtimeType == other.runtimeType &&
|
||||||
typeId == other.typeId;
|
typeId == other.typeId;
|
||||||
}
|
}
|
26
flutter/lib/models/sender_name_statistics.dart
Normal file
26
flutter/lib/models/sender_name_statistics.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
@@ -63,6 +63,33 @@ class User {
|
|||||||
maxUserMessageIDLength: json['max_user_message_id_length'] as int,
|
maxUserMessageIDLength: json['max_user_message_id_length'] as int,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'user_id': userID,
|
||||||
|
'username': username,
|
||||||
|
'timestamp_created': timestampCreated,
|
||||||
|
'timestamp_lastread': timestampLastRead,
|
||||||
|
'timestamp_lastsent': timestampLastSent,
|
||||||
|
'messages_sent': messagesSent,
|
||||||
|
'quota_used': quotaUsed,
|
||||||
|
'quota_remaining': quotaRemaining,
|
||||||
|
'quota_max': quotaPerDay,
|
||||||
|
'is_pro': isPro,
|
||||||
|
'default_channel': defaultChannel,
|
||||||
|
'max_body_size': maxBodySize,
|
||||||
|
'max_title_length': maxTitleLength,
|
||||||
|
'default_priority': defaultPriority,
|
||||||
|
'max_channel_name_length': maxChannelNameLength,
|
||||||
|
'max_channel_description_length': maxChannelDescriptionLength,
|
||||||
|
'max_sender_name_length': maxSenderNameLength,
|
||||||
|
'max_user_message_id_length': maxUserMessageIDLength,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
UserPreview toPreview() {
|
||||||
|
return UserPreview(userID: userID, username: username);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class UserWithClientsAndKeys {
|
class UserWithClientsAndKeys {
|
||||||
|
@@ -59,8 +59,7 @@ class _SCNNavLayoutState extends State<SCNNavLayout> {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: SCNAppBar(
|
appBar: SCNAppBar(
|
||||||
title: null,
|
title: null,
|
||||||
showDebug: true,
|
showSearch: _selectedIndex == 0,
|
||||||
showSearch: _selectedIndex == 0 || _selectedIndex == 1,
|
|
||||||
showShare: false,
|
showShare: false,
|
||||||
showThemeSwitch: true,
|
showThemeSwitch: true,
|
||||||
),
|
),
|
||||||
@@ -77,6 +76,7 @@ class _SCNNavLayoutState extends State<SCNNavLayout> {
|
|||||||
bottomNavigationBar: _buildNavBar(context),
|
bottomNavigationBar: _buildNavBar(context),
|
||||||
floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
|
floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
|
||||||
floatingActionButton: HidableFAB(
|
floatingActionButton: HidableFAB(
|
||||||
|
heroTag: 'fab_main',
|
||||||
onPressed: _onFABTapped,
|
onPressed: _onFABTapped,
|
||||||
icon: FontAwesomeIcons.solidPaperPlaneTop,
|
icon: FontAwesomeIcons.solidPaperPlaneTop,
|
||||||
),
|
),
|
||||||
|
@@ -7,6 +7,7 @@ import 'package:provider/provider.dart';
|
|||||||
import 'package:simplecloudnotifier/api/api_client.dart';
|
import 'package:simplecloudnotifier/api/api_client.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/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';
|
||||||
@@ -32,6 +33,7 @@ class _AccountRootPageState extends State<AccountRootPage> {
|
|||||||
late ImmediateFuture<int>? futureKeyCount;
|
late ImmediateFuture<int>? futureKeyCount;
|
||||||
late ImmediateFuture<int>? futureChannelAllCount;
|
late ImmediateFuture<int>? futureChannelAllCount;
|
||||||
late ImmediateFuture<int>? futureChannelSubscribedCount;
|
late ImmediateFuture<int>? futureChannelSubscribedCount;
|
||||||
|
late ImmediateFuture<int>? futureSenderNamesCount;
|
||||||
late ImmediateFuture<User>? futureUser;
|
late ImmediateFuture<User>? futureUser;
|
||||||
|
|
||||||
late AppAuth userAcc;
|
late AppAuth userAcc;
|
||||||
@@ -87,6 +89,7 @@ class _AccountRootPageState extends State<AccountRootPage> {
|
|||||||
futureKeyCount = null;
|
futureKeyCount = null;
|
||||||
futureChannelAllCount = null;
|
futureChannelAllCount = null;
|
||||||
futureChannelSubscribedCount = null;
|
futureChannelSubscribedCount = null;
|
||||||
|
futureSenderNamesCount = null;
|
||||||
|
|
||||||
if (userAcc.isAuth()) {
|
if (userAcc.isAuth()) {
|
||||||
futureChannelAllCount = ImmediateFuture.ofFuture(() async {
|
futureChannelAllCount = ImmediateFuture.ofFuture(() async {
|
||||||
@@ -119,6 +122,12 @@ class _AccountRootPageState extends State<AccountRootPage> {
|
|||||||
return keys.length;
|
return keys.length;
|
||||||
}());
|
}());
|
||||||
|
|
||||||
|
futureSenderNamesCount = ImmediateFuture.ofFuture(() async {
|
||||||
|
if (!userAcc.isAuth()) throw new Exception('not logged in');
|
||||||
|
final senders = (await APIClient.getSenderNameList(userAcc)).map((p) => p.name).toList();
|
||||||
|
return senders.length;
|
||||||
|
}());
|
||||||
|
|
||||||
futureUser = ImmediateFuture.ofFuture(userAcc.loadUser(force: false));
|
futureUser = ImmediateFuture.ofFuture(userAcc.loadUser(force: false));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,6 +146,7 @@ class _AccountRootPageState extends State<AccountRootPage> {
|
|||||||
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(() {
|
||||||
@@ -145,6 +155,7 @@ class _AccountRootPageState extends State<AccountRootPage> {
|
|||||||
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) {
|
||||||
@@ -368,7 +379,10 @@ class _AccountRootPageState extends State<AccountRootPage> {
|
|||||||
_buildNumberCard(context, 'Subscriptions', futureSubscriptionCount, () {/*TODO*/}),
|
_buildNumberCard(context, 'Subscriptions', futureSubscriptionCount, () {/*TODO*/}),
|
||||||
_buildNumberCard(context, 'Clients', futureClientCount, () {/*TODO*/}),
|
_buildNumberCard(context, 'Clients', futureClientCount, () {/*TODO*/}),
|
||||||
_buildNumberCard(context, 'Keys', futureKeyCount, () {/*TODO*/}),
|
_buildNumberCard(context, 'Keys', futureKeyCount, () {/*TODO*/}),
|
||||||
_buildNumberCard(context, 'Channels', futureChannelSubscribedCount, () {/*TODO*/}),
|
_buildNumberCard(context, 'Channels', futureChannelSubscribedCount, () {
|
||||||
|
Navi.push(context, () => ChannelListExtendedPage());
|
||||||
|
}),
|
||||||
|
_buildNumberCard(context, 'Sender', futureSenderNamesCount, () {/*TODO*/}),
|
||||||
UI.buttonCard(
|
UI.buttonCard(
|
||||||
context: context,
|
context: context,
|
||||||
margin: EdgeInsets.fromLTRB(0, 4, 0, 4),
|
margin: EdgeInsets.fromLTRB(0, 4, 0, 4),
|
||||||
|
@@ -140,13 +140,15 @@ 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final user = await APIClient.getUser(DirectTokenSource(uid, atokv), uid);
|
final user = await APIClient.getUser(DirectTokenSource(uid, atokv), uid);
|
||||||
|
|
||||||
final client = await APIClient.addClient(DirectTokenSource(uid, atokv), fcmToken, Globals().deviceModel, Globals().version, Globals().hostname, Globals().clientType);
|
final client = await APIClient.addClient(DirectTokenSource(uid, atokv), fcmToken, Globals().deviceModel, Globals().version, Globals().hostname, Globals().clientType);
|
||||||
|
@@ -1,12 +1,15 @@
|
|||||||
import 'package:flutter/material.dart';
|
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: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_list/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';
|
||||||
import 'package:simplecloudnotifier/pages/channel_list/channel_list_item.dart';
|
import 'package:simplecloudnotifier/pages/channel_list/channel_list_item.dart';
|
||||||
|
import 'package:simplecloudnotifier/utils/navi.dart';
|
||||||
|
|
||||||
class ChannelRootPage extends StatefulWidget {
|
class ChannelRootPage extends StatefulWidget {
|
||||||
const ChannelRootPage({super.key, required this.isVisiblePage});
|
const ChannelRootPage({super.key, required this.isVisiblePage});
|
||||||
@@ -17,11 +20,13 @@ class ChannelRootPage extends StatefulWidget {
|
|||||||
State<ChannelRootPage> createState() => _ChannelRootPageState();
|
State<ChannelRootPage> createState() => _ChannelRootPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ChannelRootPageState extends State<ChannelRootPage> {
|
class _ChannelRootPageState extends State<ChannelRootPage> with RouteAware {
|
||||||
final PagingController<int, Channel> _pagingController = PagingController.fromValue(PagingState(nextPageKey: null, itemList: [], error: null), firstPageKey: 0);
|
final PagingController<int, ChannelWithSubscription> _pagingController = PagingController.fromValue(PagingState(nextPageKey: null, itemList: [], error: null), firstPageKey: 0);
|
||||||
|
|
||||||
bool _isInitialized = false;
|
bool _isInitialized = false;
|
||||||
|
|
||||||
|
bool _reloadEnqueued = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -31,10 +36,17 @@ class _ChannelRootPageState extends State<ChannelRootPage> {
|
|||||||
if (widget.isVisiblePage && !_isInitialized) _realInitState();
|
if (widget.isVisiblePage && !_isInitialized) _realInitState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
Navi.modalRouteObserver.subscribe(this, ModalRoute.of(context)!);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
ApplicationLog.debug('ChannelRootPage::dispose');
|
ApplicationLog.debug('ChannelRootPage::dispose');
|
||||||
_pagingController.dispose();
|
_pagingController.dispose();
|
||||||
|
Navi.modalRouteObserver.unsubscribe(this);
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,6 +63,24 @@ class _ChannelRootPageState extends State<ChannelRootPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didPush() {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didPopNext() {
|
||||||
|
if (_reloadEnqueued) {
|
||||||
|
ApplicationLog.debug('[ChannelList::RouteObserver] --> didPopNext (will background-refresh) (_reloadEnqueued == true)');
|
||||||
|
() async {
|
||||||
|
_reloadEnqueued = false;
|
||||||
|
AppBarState().setLoadingIndeterminate(true);
|
||||||
|
await Future.delayed(const Duration(milliseconds: 500), () {}); // prevents flutter bug where the whole process crashes ?!?
|
||||||
|
await _backgroundRefresh();
|
||||||
|
}();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _realInitState() {
|
void _realInitState() {
|
||||||
ApplicationLog.debug('ChannelRootPage::_realInitState');
|
ApplicationLog.debug('ChannelRootPage::_realInitState');
|
||||||
_pagingController.refresh();
|
_pagingController.refresh();
|
||||||
@@ -68,9 +98,9 @@ class _ChannelRootPageState extends State<ChannelRootPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final items = (await APIClient.getChannelList(acc, ChannelSelector.all)).map((p) => p.channel).toList();
|
final items = (await APIClient.getChannelList(acc, ChannelSelector.all)).toList();
|
||||||
|
|
||||||
items.sort((a, b) => -1 * (a.timestampLastSent ?? '').compareTo(b.timestampLastSent ?? ''));
|
items.sort((a, b) => -1 * (a.channel.timestampLastSent ?? '').compareTo(b.channel.timestampLastSent ?? ''));
|
||||||
|
|
||||||
_pagingController.value = PagingState(nextPageKey: null, itemList: items, error: null);
|
_pagingController.value = PagingState(nextPageKey: null, itemList: items, error: null);
|
||||||
} catch (exc, trace) {
|
} catch (exc, trace) {
|
||||||
@@ -94,13 +124,17 @@ class _ChannelRootPageState extends State<ChannelRootPage> {
|
|||||||
|
|
||||||
AppBarState().setLoadingIndeterminate(true);
|
AppBarState().setLoadingIndeterminate(true);
|
||||||
|
|
||||||
final items = (await APIClient.getChannelList(acc, ChannelSelector.all)).map((p) => p.channel).toList();
|
final items = (await APIClient.getChannelList(acc, ChannelSelector.all)).toList();
|
||||||
|
|
||||||
items.sort((a, b) => -1 * (a.timestampLastSent ?? '').compareTo(b.timestampLastSent ?? ''));
|
items.sort((a, b) => -1 * (a.channel.timestampLastSent ?? '').compareTo(b.channel.timestampLastSent ?? ''));
|
||||||
|
|
||||||
_pagingController.value = PagingState(nextPageKey: null, itemList: items, error: null);
|
setState(() {
|
||||||
|
_pagingController.value = PagingState(nextPageKey: null, itemList: items, error: null);
|
||||||
|
});
|
||||||
} catch (exc, trace) {
|
} catch (exc, trace) {
|
||||||
_pagingController.error = exc.toString();
|
setState(() {
|
||||||
|
_pagingController.error = exc.toString();
|
||||||
|
});
|
||||||
ApplicationLog.error('Failed to list channels: ' + exc.toString(), trace: trace);
|
ApplicationLog.error('Failed to list channels: ' + exc.toString(), trace: trace);
|
||||||
} finally {
|
} finally {
|
||||||
AppBarState().setLoadingIndeterminate(false);
|
AppBarState().setLoadingIndeterminate(false);
|
||||||
@@ -109,19 +143,40 @@ class _ChannelRootPageState extends State<ChannelRootPage> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return RefreshIndicator(
|
return Scaffold(
|
||||||
onRefresh: () => Future.sync(
|
body: RefreshIndicator(
|
||||||
() => _pagingController.refresh(),
|
onRefresh: () => Future.sync(
|
||||||
),
|
() => _pagingController.refresh(),
|
||||||
child: PagedListView<int, Channel>(
|
),
|
||||||
pagingController: _pagingController,
|
child: PagedListView<int, ChannelWithSubscription>(
|
||||||
builderDelegate: PagedChildBuilderDelegate<Channel>(
|
pagingController: _pagingController,
|
||||||
itemBuilder: (context, item, index) => ChannelListItem(
|
builderDelegate: PagedChildBuilderDelegate<ChannelWithSubscription>(
|
||||||
channel: item,
|
itemBuilder: (context, item, index) => ChannelListItem(
|
||||||
onPressed: () {/*TODO*/},
|
channel: item.channel,
|
||||||
|
subscription: item.subscription,
|
||||||
|
mode: ChannelListItemMode.Messages,
|
||||||
|
onChannelListReloadTrigger: _enqueueReload,
|
||||||
|
onSubscriptionChanged: (channelID, subscription) {
|
||||||
|
setState(() {
|
||||||
|
final idx = _pagingController.itemList?.indexWhere((p) => p.channel.channelID == channelID);
|
||||||
|
if (idx != null && idx >= 0) _pagingController.itemList![idx] = ChannelWithSubscription(channel: _pagingController.itemList![idx].channel, subscription: subscription);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
floatingActionButton: FloatingActionButton(
|
||||||
|
heroTag: 'fab_channel_list_qr',
|
||||||
|
onPressed: () {
|
||||||
|
Navi.push(context, () => ChannelScannerPage());
|
||||||
|
},
|
||||||
|
child: const Icon(FontAwesomeIcons.qrcode),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _enqueueReload() {
|
||||||
|
_reloadEnqueued = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
151
flutter/lib/pages/channel_list/channel_list_extended.dart
Normal file
151
flutter/lib/pages/channel_list/channel_list_extended.dart
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:simplecloudnotifier/api/api_client.dart';
|
||||||
|
import 'package:simplecloudnotifier/components/layout/scaffold.dart';
|
||||||
|
import 'package:simplecloudnotifier/models/channel.dart';
|
||||||
|
import 'package:simplecloudnotifier/state/app_bar_state.dart';
|
||||||
|
import 'package:simplecloudnotifier/state/application_log.dart';
|
||||||
|
import 'package:simplecloudnotifier/state/app_auth.dart';
|
||||||
|
import 'package:simplecloudnotifier/pages/channel_list/channel_list_item.dart';
|
||||||
|
import 'package:simplecloudnotifier/utils/navi.dart';
|
||||||
|
|
||||||
|
class ChannelListExtendedPage extends StatefulWidget {
|
||||||
|
const ChannelListExtendedPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ChannelListExtendedPage> createState() => _ChannelListExtendedPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChannelListExtendedPageState extends State<ChannelListExtendedPage> with RouteAware {
|
||||||
|
final PagingController<int, ChannelWithSubscription> _pagingController = PagingController.fromValue(PagingState(nextPageKey: null, itemList: [], error: null), firstPageKey: 0);
|
||||||
|
|
||||||
|
bool _reloadEnqueued = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
|
||||||
|
_pagingController.addPageRequestListener(_fetchPage);
|
||||||
|
|
||||||
|
_pagingController.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
Navi.modalRouteObserver.subscribe(this, ModalRoute.of(context)!);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
ApplicationLog.debug('ChannelRootPage::dispose');
|
||||||
|
_pagingController.dispose();
|
||||||
|
Navi.modalRouteObserver.unsubscribe(this);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didPopNext() {
|
||||||
|
if (_reloadEnqueued) {
|
||||||
|
ApplicationLog.debug('[ChannelList::RouteObserver] --> didPopNext (will background-refresh) (_reloadEnqueued == true)');
|
||||||
|
() async {
|
||||||
|
_reloadEnqueued = false;
|
||||||
|
await Future.delayed(const Duration(milliseconds: 500), () {}); // prevents flutter bug where the whole process crashes ?!?
|
||||||
|
await _backgroundRefresh();
|
||||||
|
}();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchPage(int pageKey) async {
|
||||||
|
final acc = Provider.of<AppAuth>(context, listen: false);
|
||||||
|
|
||||||
|
ApplicationLog.debug('Start ChannelList::_pagingController::_fetchPage [ ${pageKey} ]');
|
||||||
|
|
||||||
|
if (!acc.isAuth()) {
|
||||||
|
_pagingController.error = 'Not logged in';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final items = (await APIClient.getChannelList(acc, ChannelSelector.all)).toList();
|
||||||
|
|
||||||
|
items.sort((a, b) => -1 * (a.channel.timestampLastSent ?? '').compareTo(b.channel.timestampLastSent ?? ''));
|
||||||
|
|
||||||
|
_pagingController.value = PagingState(nextPageKey: null, itemList: items, error: null);
|
||||||
|
} catch (exc, trace) {
|
||||||
|
_pagingController.error = exc.toString();
|
||||||
|
ApplicationLog.error('Failed to list channels: ' + exc.toString(), trace: trace);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _backgroundRefresh() async {
|
||||||
|
final acc = Provider.of<AppAuth>(context, listen: false);
|
||||||
|
|
||||||
|
ApplicationLog.debug('Start background refresh of channel list');
|
||||||
|
|
||||||
|
if (!acc.isAuth()) {
|
||||||
|
_pagingController.error = 'Not logged in';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Future.delayed(const Duration(seconds: 0), () {}); // this is annoyingly important - otherwise we call setLoadingIndeterminate directly in initStat() and get an exception....
|
||||||
|
|
||||||
|
AppBarState().setLoadingIndeterminate(true);
|
||||||
|
|
||||||
|
final items = (await APIClient.getChannelList(acc, ChannelSelector.all)).toList();
|
||||||
|
|
||||||
|
items.sort((a, b) => -1 * (a.channel.timestampLastSent ?? '').compareTo(b.channel.timestampLastSent ?? ''));
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_pagingController.value = PagingState(nextPageKey: null, itemList: items, error: null);
|
||||||
|
});
|
||||||
|
} catch (exc, trace) {
|
||||||
|
setState(() {
|
||||||
|
_pagingController.error = exc.toString();
|
||||||
|
});
|
||||||
|
ApplicationLog.error('Failed to list channels: ' + exc.toString(), trace: trace);
|
||||||
|
} finally {
|
||||||
|
AppBarState().setLoadingIndeterminate(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SCNScaffold(
|
||||||
|
title: "Channels",
|
||||||
|
showSearch: false,
|
||||||
|
showShare: false,
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(8, 4, 8, 4),
|
||||||
|
child: RefreshIndicator(
|
||||||
|
onRefresh: () => Future.sync(
|
||||||
|
() => _pagingController.refresh(),
|
||||||
|
),
|
||||||
|
child: PagedListView<int, ChannelWithSubscription>(
|
||||||
|
pagingController: _pagingController,
|
||||||
|
builderDelegate: PagedChildBuilderDelegate<ChannelWithSubscription>(
|
||||||
|
itemBuilder: (context, item, index) => ChannelListItem(
|
||||||
|
channel: item.channel,
|
||||||
|
subscription: item.subscription,
|
||||||
|
mode: ChannelListItemMode.Extended,
|
||||||
|
onChannelListReloadTrigger: _enqueueReload,
|
||||||
|
onSubscriptionChanged: (channelID, subscription) {
|
||||||
|
setState(() {
|
||||||
|
final idx = _pagingController.itemList?.indexWhere((p) => p.channel.channelID == channelID);
|
||||||
|
if (idx != null && idx >= 0) _pagingController.itemList![idx] = ChannelWithSubscription(channel: _pagingController.itemList![idx].channel, subscription: subscription);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _enqueueReload() {
|
||||||
|
_reloadEnqueued = true;
|
||||||
|
}
|
||||||
|
}
|
@@ -1,29 +1,48 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||||
import 'package:intl/intl.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/message.dart';
|
import 'package:simplecloudnotifier/models/scn_message.dart';
|
||||||
|
import 'package:simplecloudnotifier/models/subscription.dart';
|
||||||
|
import 'package:simplecloudnotifier/pages/channel_message_view/channel_message_view.dart';
|
||||||
|
import 'package:simplecloudnotifier/pages/channel_view/channel_view.dart';
|
||||||
import 'package:simplecloudnotifier/state/app_auth.dart';
|
import 'package:simplecloudnotifier/state/app_auth.dart';
|
||||||
|
import 'package:simplecloudnotifier/state/application_log.dart';
|
||||||
|
import 'package:simplecloudnotifier/state/scn_data_cache.dart';
|
||||||
|
import 'package:simplecloudnotifier/utils/navi.dart';
|
||||||
|
import 'package:simplecloudnotifier/utils/toaster.dart';
|
||||||
|
|
||||||
|
enum ChannelListItemMode {
|
||||||
|
Messages,
|
||||||
|
Extended,
|
||||||
|
}
|
||||||
|
|
||||||
class ChannelListItem extends StatefulWidget {
|
class ChannelListItem extends StatefulWidget {
|
||||||
static final _dateFormat = DateFormat('yyyy-MM-dd kk:mm');
|
static final _dateFormat = DateFormat('yyyy-MM-dd kk:mm');
|
||||||
|
|
||||||
const ChannelListItem({
|
const ChannelListItem({
|
||||||
required this.channel,
|
required this.channel,
|
||||||
required this.onPressed,
|
required this.onChannelListReloadTrigger,
|
||||||
|
required this.onSubscriptionChanged,
|
||||||
|
required this.subscription,
|
||||||
|
required this.mode,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
final Channel channel;
|
final Channel channel;
|
||||||
final Null Function() onPressed;
|
final Subscription? subscription;
|
||||||
|
final void Function() onChannelListReloadTrigger;
|
||||||
|
final ChannelListItemMode mode;
|
||||||
|
final void Function(String, Subscription?) onSubscriptionChanged;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ChannelListItem> createState() => _ChannelListItemState();
|
State<ChannelListItem> createState() => _ChannelListItemState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ChannelListItemState extends State<ChannelListItem> {
|
class _ChannelListItemState extends State<ChannelListItem> {
|
||||||
Message? lastMessage;
|
SCNMessage? lastMessage;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -31,9 +50,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;
|
||||||
|
|
||||||
() async {
|
() async {
|
||||||
final (_, channelMessages) = await APIClient.getMessageList(acc, '@start', pageSize: 1, channelIDs: [widget.channel.channelID]);
|
final (_, channelMessages) = await APIClient.getChannelMessageList(acc, widget.channel.channelID, '@start', pageSize: 1);
|
||||||
setState(() {
|
setState(() {
|
||||||
lastMessage = channelMessages.firstOrNull;
|
lastMessage = channelMessages.firstOrNull;
|
||||||
});
|
});
|
||||||
@@ -43,45 +64,66 @@ class _ChannelListItemState extends State<ChannelListItem> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
//TODO subscription status
|
|
||||||
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(
|
||||||
splashColor: Theme.of(context).splashColor,
|
onTap: () {
|
||||||
onTap: widget.onPressed,
|
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: Column(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
children: [
|
||||||
Row(
|
_buildIcon(context),
|
||||||
children: [
|
SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Column(
|
||||||
widget.channel.displayName,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
widget.channel.displayName,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
(widget.channel.timestampLastSent == null) ? '' : ChannelListItem._dateFormat.format(DateTime.parse(widget.channel.timestampLastSent!).toLocal()),
|
||||||
|
style: const TextStyle(fontSize: 14),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
SizedBox(height: 4),
|
||||||
Text(
|
Row(
|
||||||
(widget.channel.timestampLastSent == null) ? '' : ChannelListItem._dateFormat.format(DateTime.parse(widget.channel.timestampLastSent!).toLocal()),
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
style: const TextStyle(fontSize: 14),
|
children: [
|
||||||
),
|
Expanded(child: (widget.mode == ChannelListItemMode.Messages) ? Text(_preformatTitle(lastMessage), style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(160))) : _buildSubscriptionStateText(context)),
|
||||||
],
|
(widget.mode == ChannelListItemMode.Messages) ? Text(widget.channel.messagesSent.toString(), style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)) : Text("", style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
SizedBox(height: 4),
|
SizedBox(width: 4),
|
||||||
Row(
|
GestureDetector(
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
onTap: () {
|
||||||
children: [
|
if (widget.mode == ChannelListItemMode.Messages) {
|
||||||
Expanded(
|
Navi.push(context, () => ChannelViewPage(channelID: widget.channel.channelID, preloadedData: (widget.channel, widget.subscription), needsReload: widget.onChannelListReloadTrigger));
|
||||||
child: Text(
|
} else {
|
||||||
lastMessage?.title ?? '...',
|
Navi.push(context, () => ChannelMessageViewPage(channel: widget.channel));
|
||||||
style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(160)),
|
}
|
||||||
),
|
},
|
||||||
),
|
child: Padding(
|
||||||
Text(widget.channel.messagesSent.toString(), style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
|
padding: const EdgeInsets.all(8),
|
||||||
],
|
child: (widget.mode == ChannelListItemMode.Messages) ? Icon(FontAwesomeIcons.solidSquareInfo, color: Theme.of(context).colorScheme.onPrimaryContainer.withAlpha(128), size: 24) : Icon(FontAwesomeIcons.solidEnvelopes, color: Theme.of(context).colorScheme.onPrimaryContainer.withAlpha(128), size: 24),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -89,4 +131,81 @@ class _ChannelListItemState extends State<ChannelListItem> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _preformatTitle(SCNMessage? message) {
|
||||||
|
if (message == null) return '...';
|
||||||
|
return message.title.replaceAll('\n', '').replaceAll('\r', '').replaceAll('\t', ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildIcon(BuildContext context) {
|
||||||
|
if (widget.subscription == null) {
|
||||||
|
Widget result = Icon(FontAwesomeIcons.solidSquareDashed, color: Theme.of(context).colorScheme.outline, size: 32); // not-subscribed
|
||||||
|
result = GestureDetector(onTap: () => _subscribe(), child: result);
|
||||||
|
return result;
|
||||||
|
} else if (widget.subscription!.confirmed && widget.channel.ownerUserID == widget.subscription!.subscriberUserID) {
|
||||||
|
Widget result = Icon(FontAwesomeIcons.solidSquareRss, color: Theme.of(context).colorScheme.onPrimaryContainer, size: 32); // subscribed (own channel)
|
||||||
|
result = GestureDetector(onTap: () => _unsubscribe(widget.subscription!), child: result);
|
||||||
|
return result;
|
||||||
|
} else if (widget.subscription!.confirmed) {
|
||||||
|
Widget result = Icon(FontAwesomeIcons.solidSquareShareNodes, color: Theme.of(context).colorScheme.onPrimaryContainer, size: 32); // subscribed (foreign channel)
|
||||||
|
result = GestureDetector(onTap: () => _unsubscribe(widget.subscription!), child: result);
|
||||||
|
return result;
|
||||||
|
} else {
|
||||||
|
Widget result = Icon(FontAwesomeIcons.solidSquareEnvelope, color: Theme.of(context).colorScheme.tertiary, size: 32); // requested
|
||||||
|
result = GestureDetector(onTap: () => _unsubscribe(widget.subscription!), child: result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSubscriptionStateText(BuildContext context) {
|
||||||
|
if (widget.subscription == null) {
|
||||||
|
return Text("", style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(160)));
|
||||||
|
} else if (widget.subscription!.confirmed && widget.channel.ownerUserID == widget.subscription!.subscriberUserID) {
|
||||||
|
return Text("subscribed", style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(160)));
|
||||||
|
} else if (widget.subscription!.confirmed) {
|
||||||
|
return Text("subscripted (foreign channe)", style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(160)));
|
||||||
|
} else {
|
||||||
|
return Text("subscription requested", style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(160)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _subscribe() async {
|
||||||
|
final acc = AppAuth();
|
||||||
|
|
||||||
|
if (acc.isAuth() && widget.channel.ownerUserID == acc.getUserID()) {
|
||||||
|
try {
|
||||||
|
var sub = await APIClient.subscribeToChannelbyID(acc, widget.channel.channelID);
|
||||||
|
widget.onChannelListReloadTrigger.call();
|
||||||
|
|
||||||
|
widget.onSubscriptionChanged(widget.channel.channelID, sub);
|
||||||
|
|
||||||
|
if (sub.confirmed) {
|
||||||
|
Toaster.success("Success", 'Subscribed to channel');
|
||||||
|
} else {
|
||||||
|
Toaster.success("Success", 'Requested widget.subscription to channel');
|
||||||
|
}
|
||||||
|
} catch (exc, trace) {
|
||||||
|
Toaster.error("Error", 'Failed to subscribe to channel');
|
||||||
|
ApplicationLog.error('Failed to subscribe to channel: ' + exc.toString(), trace: trace);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _unsubscribe(Subscription sub) async {
|
||||||
|
final acc = AppAuth();
|
||||||
|
|
||||||
|
if (acc.isAuth() && widget.channel.ownerUserID == acc.getUserID() && widget.subscription != null) {
|
||||||
|
try {
|
||||||
|
await APIClient.deleteSubscription(acc, widget.channel.channelID, widget.subscription!.subscriptionID);
|
||||||
|
widget.onChannelListReloadTrigger.call();
|
||||||
|
|
||||||
|
widget.onSubscriptionChanged.call(widget.channel.channelID, null);
|
||||||
|
|
||||||
|
Toaster.success("Success", 'Unsubscribed from channel');
|
||||||
|
} catch (exc, trace) {
|
||||||
|
Toaster.error("Error", 'Failed to unsubscribe from channel');
|
||||||
|
ApplicationLog.error('Failed to unsubscribe from channel: ' + exc.toString(), trace: trace);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
117
flutter/lib/pages/channel_list/channel_scanner.dart
Normal file
117
flutter/lib/pages/channel_list/channel_scanner.dart
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||||
|
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||||
|
import 'package:simplecloudnotifier/components/layout/scaffold.dart';
|
||||||
|
import 'package:simplecloudnotifier/models/scan_result.dart';
|
||||||
|
import 'package:simplecloudnotifier/utils/ui.dart';
|
||||||
|
|
||||||
|
class ChannelScannerPage extends StatefulWidget {
|
||||||
|
const ChannelScannerPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ChannelScannerPage> createState() => _ChannelScannerPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChannelScannerPageState extends State<ChannelScannerPage> {
|
||||||
|
final MobileScannerController _controller = MobileScannerController(
|
||||||
|
formats: const [BarcodeFormat.qrCode],
|
||||||
|
);
|
||||||
|
|
||||||
|
ScanResult? scanResult = null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SCNScaffold(
|
||||||
|
title: "Scanner",
|
||||||
|
showSearch: false,
|
||||||
|
showShare: false,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 16, 24, 16),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
SizedBox(height: 16),
|
||||||
|
Center(
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(UI.DefaultBorderRadius),
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.hardEdge,
|
||||||
|
child: SizedBox(
|
||||||
|
height: 300,
|
||||||
|
width: 300,
|
||||||
|
child: MobileScanner(
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
controller: _controller,
|
||||||
|
onDetect: _handleBarcode,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 16),
|
||||||
|
_buildScanResult(context),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleBarcode(BarcodeCapture barcodes) {
|
||||||
|
setState(() {
|
||||||
|
if (barcodes.barcodes.isEmpty) {
|
||||||
|
scanResult = null;
|
||||||
|
} else {
|
||||||
|
print('parsed: ${barcodes.barcodes[0].rawValue}');
|
||||||
|
scanResult = ScanResult.parse(barcodes.barcodes[0].rawValue ?? '');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildScanResult(BuildContext context) {
|
||||||
|
if (scanResult == null) {
|
||||||
|
return UI.box(
|
||||||
|
padding: EdgeInsets.fromLTRB(16, 2, 4, 2), //TODO
|
||||||
|
context: context,
|
||||||
|
child: Center(
|
||||||
|
child: Icon(FontAwesomeIcons.solidEmptySet, size: 64, color: Theme.of(context).colorScheme.onPrimaryContainer.withAlpha(128)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scanResult! is ScanResultMessageSend) {
|
||||||
|
return UI.box(
|
||||||
|
padding: EdgeInsets.fromLTRB(16, 2, 4, 2),
|
||||||
|
context: context,
|
||||||
|
child: Text("TODO -- ScanResultMessageSend"), //TODO
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scanResult! is ScanResultChannel) {
|
||||||
|
return UI.box(
|
||||||
|
padding: EdgeInsets.fromLTRB(16, 2, 4, 2),
|
||||||
|
context: context,
|
||||||
|
child: Text("TODO -- ScanResultChannel"), //TODO
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scanResult! is ScanResultChannelSubscribe) {
|
||||||
|
return UI.box(
|
||||||
|
padding: EdgeInsets.fromLTRB(16, 2, 4, 2),
|
||||||
|
context: context,
|
||||||
|
child: Text("TODO -- ScanResultChannelSubscribe"), //TODO
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return UI.box(
|
||||||
|
padding: EdgeInsets.fromLTRB(16, 2, 4, 2),
|
||||||
|
context: context,
|
||||||
|
child: Text("TODO -- ERROR"), //TODO
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
105
flutter/lib/pages/channel_message_view/channel_message_view.dart
Normal file
105
flutter/lib/pages/channel_message_view/channel_message_view.dart
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
|
||||||
|
import 'package:simplecloudnotifier/api/api_client.dart';
|
||||||
|
import 'package:simplecloudnotifier/components/layout/scaffold.dart';
|
||||||
|
import 'package:simplecloudnotifier/models/channel.dart';
|
||||||
|
import 'package:simplecloudnotifier/models/scn_message.dart';
|
||||||
|
import 'package:simplecloudnotifier/pages/message_list/message_list_item.dart';
|
||||||
|
import 'package:simplecloudnotifier/pages/message_view/message_view.dart';
|
||||||
|
import 'package:simplecloudnotifier/settings/app_settings.dart';
|
||||||
|
import 'package:simplecloudnotifier/state/app_auth.dart';
|
||||||
|
import 'package:simplecloudnotifier/state/application_log.dart';
|
||||||
|
import 'package:simplecloudnotifier/state/scn_data_cache.dart';
|
||||||
|
import 'package:simplecloudnotifier/utils/navi.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
class ChannelMessageViewPage extends StatefulWidget {
|
||||||
|
const ChannelMessageViewPage({
|
||||||
|
required this.channel,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Channel channel;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ChannelMessageViewPage> createState() => _ChannelMessageViewPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChannelMessageViewPageState extends State<ChannelMessageViewPage> {
|
||||||
|
PagingController<String, SCNMessage> _pagingController = PagingController.fromValue(PagingState(nextPageKey: null, itemList: [], error: null), firstPageKey: '@start');
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
|
||||||
|
_pagingController.addPageRequestListener(_fetchPage);
|
||||||
|
|
||||||
|
_pagingController.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_pagingController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchPage(String thisPageToken) async {
|
||||||
|
final acc = Provider.of<AppAuth>(context, listen: false);
|
||||||
|
final cfg = Provider.of<AppSettings>(context, listen: false);
|
||||||
|
|
||||||
|
ApplicationLog.debug('Start ChannelMessageViewPage::_pagingController::_fetchPage [ ${thisPageToken} ]');
|
||||||
|
|
||||||
|
if (!acc.isAuth()) {
|
||||||
|
_pagingController.error = 'Not logged in';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final (npt, newItems) = await APIClient.getChannelMessageList(acc, this.widget.channel.channelID, thisPageToken, pageSize: cfg.messagePageSize);
|
||||||
|
|
||||||
|
SCNDataCache().addToMessageCache(newItems); // no await
|
||||||
|
|
||||||
|
if (npt == '@end') {
|
||||||
|
_pagingController.appendLastPage(newItems);
|
||||||
|
} else {
|
||||||
|
_pagingController.appendPage(newItems, npt);
|
||||||
|
}
|
||||||
|
} catch (exc, trace) {
|
||||||
|
_pagingController.error = exc.toString();
|
||||||
|
ApplicationLog.error('Failed to list channel-messages: ' + exc.toString(), trace: trace);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SCNScaffold(
|
||||||
|
title: this.widget.channel.displayName,
|
||||||
|
showSearch: false,
|
||||||
|
showShare: false,
|
||||||
|
child: _buildMessageList(context),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMessageList(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(8, 4, 8, 4),
|
||||||
|
child: RefreshIndicator(
|
||||||
|
onRefresh: () => Future.sync(
|
||||||
|
() => _pagingController.refresh(),
|
||||||
|
),
|
||||||
|
child: PagedListView<String, SCNMessage>(
|
||||||
|
pagingController: _pagingController,
|
||||||
|
builderDelegate: PagedChildBuilderDelegate<SCNMessage>(
|
||||||
|
itemBuilder: (context, item, index) => MessageListItem(
|
||||||
|
message: item,
|
||||||
|
allChannels: {this.widget.channel.channelID: this.widget.channel},
|
||||||
|
onPressed: () {
|
||||||
|
Navi.push(context, () => MessageViewPage(messageID: item.messageID, preloadedData: (item,)));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
671
flutter/lib/pages/channel_view/channel_view.dart
Normal file
671
flutter/lib/pages/channel_view/channel_view.dart
Normal file
@@ -0,0 +1,671 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||||
|
import 'package:qr_flutter/qr_flutter.dart';
|
||||||
|
import 'package:share_plus/share_plus.dart';
|
||||||
|
import 'package:simplecloudnotifier/api/api_client.dart';
|
||||||
|
import 'package:simplecloudnotifier/components/layout/scaffold.dart';
|
||||||
|
import 'package:simplecloudnotifier/models/channel.dart';
|
||||||
|
import 'package:simplecloudnotifier/models/scan_result.dart';
|
||||||
|
import 'package:simplecloudnotifier/models/subscription.dart';
|
||||||
|
import 'package:simplecloudnotifier/models/user.dart';
|
||||||
|
import 'package:simplecloudnotifier/pages/channel_message_view/channel_message_view.dart';
|
||||||
|
import 'package:simplecloudnotifier/state/app_auth.dart';
|
||||||
|
import 'package:simplecloudnotifier/state/app_bar_state.dart';
|
||||||
|
import 'package:simplecloudnotifier/state/application_log.dart';
|
||||||
|
import 'package:simplecloudnotifier/types/immediate_future.dart';
|
||||||
|
import 'package:simplecloudnotifier/utils/navi.dart';
|
||||||
|
import 'package:simplecloudnotifier/utils/toaster.dart';
|
||||||
|
import 'package:simplecloudnotifier/utils/ui.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
class ChannelViewPage extends StatefulWidget {
|
||||||
|
const ChannelViewPage({
|
||||||
|
required this.channelID,
|
||||||
|
required this.preloadedData,
|
||||||
|
required this.needsReload,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String channelID;
|
||||||
|
final (Channel, Subscription?)? preloadedData;
|
||||||
|
|
||||||
|
final void Function()? needsReload;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ChannelViewPage> createState() => _ChannelViewPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
enum EditState { none, editing, saving }
|
||||||
|
|
||||||
|
enum ChannelViewPageInitState { loading, okay, error }
|
||||||
|
|
||||||
|
class _ChannelViewPageState extends State<ChannelViewPage> {
|
||||||
|
late ImmediateFuture<String?> _futureSubscribeKey;
|
||||||
|
late ImmediateFuture<List<(Subscription, UserPreview?)>> _futureSubscriptions;
|
||||||
|
late ImmediateFuture<UserPreview> _futureOwner;
|
||||||
|
|
||||||
|
final TextEditingController _ctrlDisplayName = TextEditingController();
|
||||||
|
final TextEditingController _ctrlDescriptionName = TextEditingController();
|
||||||
|
|
||||||
|
int _loadingIndeterminateCounter = 0;
|
||||||
|
|
||||||
|
EditState _editDisplayName = EditState.none;
|
||||||
|
String? _displayNameOverride = null;
|
||||||
|
|
||||||
|
EditState _editDescriptionName = EditState.none;
|
||||||
|
String? _descriptionNameOverride = null;
|
||||||
|
|
||||||
|
ChannelPreview? channelPreview;
|
||||||
|
Channel? channel;
|
||||||
|
Subscription? subscription;
|
||||||
|
|
||||||
|
ChannelViewPageInitState loadingState = ChannelViewPageInitState.loading;
|
||||||
|
String errorMessage = '';
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
_initStateAsync(true);
|
||||||
|
|
||||||
|
super.initState();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _initStateAsync(bool usePreload) async {
|
||||||
|
final userAcc = Provider.of<AppAuth>(context, listen: false);
|
||||||
|
|
||||||
|
if (widget.preloadedData != null && usePreload) {
|
||||||
|
channelPreview = widget.preloadedData!.$1.toPreview();
|
||||||
|
channel = widget.preloadedData!.$1;
|
||||||
|
subscription = widget.preloadedData!.$2;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
var p = await APIClient.getChannelPreview(userAcc, widget.channelID);
|
||||||
|
channelPreview = p;
|
||||||
|
if (p.ownerUserID == userAcc.userID) {
|
||||||
|
var r = await APIClient.getChannel(userAcc, widget.channelID);
|
||||||
|
channel = r.channel;
|
||||||
|
subscription = r.subscription;
|
||||||
|
} else {
|
||||||
|
channel = null;
|
||||||
|
subscription = null; //TODO get own subscription on this channel, even though its foreign channel
|
||||||
|
}
|
||||||
|
} catch (exc, trace) {
|
||||||
|
ApplicationLog.error('Failed to load data: ' + exc.toString(), trace: trace);
|
||||||
|
Toaster.error("Error", 'Failed to load data');
|
||||||
|
this.errorMessage = 'Failed to load data: ' + exc.toString();
|
||||||
|
this.loadingState = ChannelViewPageInitState.error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loadingState = ChannelViewPageInitState.okay;
|
||||||
|
|
||||||
|
assert(channelPreview != null);
|
||||||
|
|
||||||
|
if (this.channelPreview!.ownerUserID == userAcc.userID) {
|
||||||
|
if (this.channel != null && this.channel!.subscribeKey != null) {
|
||||||
|
_futureSubscribeKey = ImmediateFuture<String?>.ofValue(this.channel!.subscribeKey);
|
||||||
|
} else {
|
||||||
|
_futureSubscribeKey = ImmediateFuture<String?>.ofFuture(_getSubscribeKey(userAcc));
|
||||||
|
}
|
||||||
|
_futureSubscriptions = ImmediateFuture<List<(Subscription, UserPreview?)>>.ofFuture(_listSubscriptions(userAcc));
|
||||||
|
} else {
|
||||||
|
_futureSubscribeKey = ImmediateFuture<String?>.ofValue(null);
|
||||||
|
_futureSubscriptions = ImmediateFuture<List<(Subscription, UserPreview?)>>.ofValue([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.channelPreview!.ownerUserID == userAcc.userID) {
|
||||||
|
var cacheUser = userAcc.getUserOrNull();
|
||||||
|
if (cacheUser != null) {
|
||||||
|
_futureOwner = ImmediateFuture<UserPreview>.ofValue(cacheUser.toPreview());
|
||||||
|
} else {
|
||||||
|
_futureOwner = ImmediateFuture<UserPreview>.ofFuture(_getOwner(userAcc));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_futureOwner = ImmediateFuture<UserPreview>.ofFuture(APIClient.getUserPreview(userAcc, this.channelPreview!.ownerUserID));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_ctrlDisplayName.dispose();
|
||||||
|
_ctrlDescriptionName.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final userAcc = Provider.of<AppAuth>(context, listen: false);
|
||||||
|
|
||||||
|
Widget child;
|
||||||
|
|
||||||
|
if (loadingState == ChannelViewPageInitState.loading) {
|
||||||
|
child = Center(child: CircularProgressIndicator());
|
||||||
|
} else if (loadingState == ChannelViewPageInitState.error) {
|
||||||
|
child = Center(child: Text('Error: ' + errorMessage)); //TODO better error
|
||||||
|
} else if (loadingState == ChannelViewPageInitState.okay && channelPreview!.ownerUserID == userAcc.userID) {
|
||||||
|
child = _buildOwnedChannelView(context, this.channel!);
|
||||||
|
} else {
|
||||||
|
child = _buildForeignChannelView(context, this.channelPreview!);
|
||||||
|
}
|
||||||
|
|
||||||
|
return SCNScaffold(
|
||||||
|
title: 'Channel',
|
||||||
|
showSearch: false,
|
||||||
|
showShare: false,
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildOwnedChannelView(BuildContext context, Channel channel) {
|
||||||
|
final isSubscribed = (subscription != null && subscription!.confirmed);
|
||||||
|
|
||||||
|
return SingleChildScrollView(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 16, 24, 16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
_buildQRCode(context),
|
||||||
|
SizedBox(height: 8),
|
||||||
|
UI.metaCard(
|
||||||
|
context: context,
|
||||||
|
icon: FontAwesomeIcons.solidIdCardClip,
|
||||||
|
title: 'ChannelID',
|
||||||
|
values: [channel.channelID],
|
||||||
|
),
|
||||||
|
UI.metaCard(
|
||||||
|
context: context,
|
||||||
|
icon: FontAwesomeIcons.solidInputNumeric,
|
||||||
|
title: 'InternalName',
|
||||||
|
values: [channel.internalName],
|
||||||
|
),
|
||||||
|
_buildDisplayNameCard(context, true),
|
||||||
|
_buildDescriptionNameCard(context, true),
|
||||||
|
UI.metaCard(
|
||||||
|
context: context,
|
||||||
|
icon: FontAwesomeIcons.solidDiagramSubtask,
|
||||||
|
title: 'Subscription (own)',
|
||||||
|
values: [_formatSubscriptionStatus(this.subscription)],
|
||||||
|
iconActions: isSubscribed ? [(FontAwesomeIcons.solidSquareXmark, _unsubscribe)] : [(FontAwesomeIcons.solidSquareRss, _subscribe)],
|
||||||
|
),
|
||||||
|
_buildForeignSubscriptions(context),
|
||||||
|
_buildOwnerCard(context, true),
|
||||||
|
UI.metaCard(
|
||||||
|
context: context,
|
||||||
|
icon: FontAwesomeIcons.solidEnvelope,
|
||||||
|
title: 'Messages',
|
||||||
|
values: [channel.messagesSent.toString()],
|
||||||
|
mainAction: () {
|
||||||
|
Navi.push(context, () => ChannelMessageViewPage(channel: channel));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildForeignChannelView(BuildContext context, ChannelPreview channel) {
|
||||||
|
final isSubscribed = (subscription != null && subscription!.confirmed);
|
||||||
|
|
||||||
|
return SingleChildScrollView(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 16, 24, 16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
SizedBox(height: 8),
|
||||||
|
UI.metaCard(
|
||||||
|
context: context,
|
||||||
|
icon: FontAwesomeIcons.solidIdCardClip,
|
||||||
|
title: 'ChannelID',
|
||||||
|
values: [channel.channelID],
|
||||||
|
),
|
||||||
|
UI.metaCard(
|
||||||
|
context: context,
|
||||||
|
icon: FontAwesomeIcons.solidInputNumeric,
|
||||||
|
title: 'InternalName',
|
||||||
|
values: [channel.internalName],
|
||||||
|
),
|
||||||
|
_buildDisplayNameCard(context, false),
|
||||||
|
_buildDescriptionNameCard(context, false),
|
||||||
|
UI.metaCard(
|
||||||
|
context: context,
|
||||||
|
icon: FontAwesomeIcons.solidDiagramSubtask,
|
||||||
|
title: 'Subscription (foreign)',
|
||||||
|
values: [_formatSubscriptionStatus(subscription)],
|
||||||
|
iconActions: isSubscribed ? [(FontAwesomeIcons.solidSquareXmark, _unsubscribe)] : [(FontAwesomeIcons.solidSquareRss, _subscribe)],
|
||||||
|
),
|
||||||
|
_buildForeignSubscriptions(context),
|
||||||
|
_buildOwnerCard(context, false),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildForeignSubscriptions(BuildContext context) {
|
||||||
|
return FutureBuilder(
|
||||||
|
future: _futureSubscriptions.future,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.hasData) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
for (final (sub, user) in snapshot.data!.where((v) => v.$1.subscriptionID != subscription?.subscriptionID))
|
||||||
|
UI.metaCard(
|
||||||
|
context: context,
|
||||||
|
icon: FontAwesomeIcons.solidDiagramSuccessor,
|
||||||
|
title: 'Subscription (' + (user?.username ?? user?.userID ?? 'other') + ')',
|
||||||
|
values: [_formatSubscriptionStatus(sub)],
|
||||||
|
iconActions: _getForeignSubActions(sub),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return SizedBox();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildOwnerCard(BuildContext context, bool isOwned) {
|
||||||
|
return FutureBuilder(
|
||||||
|
future: _futureOwner.future,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.hasData) {
|
||||||
|
return UI.metaCard(
|
||||||
|
context: context,
|
||||||
|
icon: FontAwesomeIcons.solidUser,
|
||||||
|
title: 'Owner',
|
||||||
|
values: [channelPreview!.ownerUserID + (isOwned ? ' (you)' : ''), if (snapshot.data?.username != null) snapshot.data!.username!],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return UI.metaCard(
|
||||||
|
context: context,
|
||||||
|
icon: FontAwesomeIcons.solidUser,
|
||||||
|
title: 'Owner',
|
||||||
|
values: [channelPreview!.ownerUserID + (isOwned ? ' (you)' : '')],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildQRCode(BuildContext context) {
|
||||||
|
return FutureBuilder(
|
||||||
|
future: _futureSubscribeKey.future,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.hasData) {
|
||||||
|
final text = (snapshot.data == null) ? ScanResult.createChannelQR(channel!) : ScanResult.createChannelSubscribeQR(channel!, snapshot.data!);
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
Share.share(text, subject: _displayNameOverride ?? channel!.displayName);
|
||||||
|
},
|
||||||
|
child: Center(
|
||||||
|
child: QrImageView(
|
||||||
|
data: text,
|
||||||
|
version: QrVersions.auto,
|
||||||
|
size: 265.0,
|
||||||
|
eyeStyle: QrEyeStyle(
|
||||||
|
eyeShape: QrEyeShape.square,
|
||||||
|
color: Theme.of(context).textTheme.bodyLarge?.color,
|
||||||
|
),
|
||||||
|
dataModuleStyle: QrDataModuleStyle(
|
||||||
|
dataModuleShape: QrDataModuleShape.square,
|
||||||
|
color: Theme.of(context).textTheme.bodyLarge?.color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return const SizedBox(
|
||||||
|
width: 300.0,
|
||||||
|
height: 300.0,
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDisplayNameCard(BuildContext context, bool isOwned) {
|
||||||
|
if (_editDisplayName == EditState.editing) {
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 4, horizontal: 0),
|
||||||
|
child: UI.box(
|
||||||
|
context: context,
|
||||||
|
padding: EdgeInsets.fromLTRB(16, 2, 4, 2),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(child: Center(child: FaIcon(FontAwesomeIcons.solidInputText, size: 18)), height: 43),
|
||||||
|
SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
autofocus: true,
|
||||||
|
controller: _ctrlDisplayName,
|
||||||
|
decoration: new InputDecoration.collapsed(hintText: 'DisplayName'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(width: 12),
|
||||||
|
SizedBox(width: 4),
|
||||||
|
IconButton(icon: FaIcon(FontAwesomeIcons.solidFloppyDisk), onPressed: _saveDisplayName),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (_editDisplayName == EditState.none) {
|
||||||
|
return UI.metaCard(
|
||||||
|
context: context,
|
||||||
|
icon: FontAwesomeIcons.solidInputText,
|
||||||
|
title: 'DisplayName',
|
||||||
|
values: [_displayNameOverride ?? channelPreview!.displayName],
|
||||||
|
iconActions: isOwned ? [(FontAwesomeIcons.penToSquare, _showEditDisplayName)] : [],
|
||||||
|
);
|
||||||
|
} else if (_editDisplayName == EditState.saving) {
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 4, horizontal: 0),
|
||||||
|
child: UI.box(
|
||||||
|
context: context,
|
||||||
|
padding: EdgeInsets.fromLTRB(16, 2, 4, 2),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(child: Center(child: FaIcon(FontAwesomeIcons.solidInputText, size: 18)), height: 43),
|
||||||
|
SizedBox(width: 16),
|
||||||
|
Expanded(child: SizedBox()),
|
||||||
|
SizedBox(width: 12),
|
||||||
|
SizedBox(width: 4),
|
||||||
|
Padding(padding: const EdgeInsets.all(8.0), child: SizedBox(width: 18, height: 18, child: CircularProgressIndicator())),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw 'Invalid EditDisplayNameState: $_editDisplayName';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDescriptionNameCard(BuildContext context, bool isOwned) {
|
||||||
|
if (_editDescriptionName == EditState.editing) {
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 4, horizontal: 0),
|
||||||
|
child: UI.box(
|
||||||
|
context: context,
|
||||||
|
padding: EdgeInsets.fromLTRB(16, 2, 4, 2),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(child: Center(child: FaIcon(FontAwesomeIcons.solidInputPipe, size: 18)), height: 43),
|
||||||
|
SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
autofocus: true,
|
||||||
|
controller: _ctrlDescriptionName,
|
||||||
|
decoration: new InputDecoration.collapsed(hintText: 'Description'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(width: 12),
|
||||||
|
SizedBox(width: 4),
|
||||||
|
IconButton(icon: FaIcon(FontAwesomeIcons.solidFloppyDisk), onPressed: _saveDescriptionName),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (_editDescriptionName == EditState.none) {
|
||||||
|
return UI.metaCard(
|
||||||
|
context: context,
|
||||||
|
icon: FontAwesomeIcons.solidInputPipe,
|
||||||
|
title: 'Description',
|
||||||
|
values: [_descriptionNameOverride ?? channelPreview?.descriptionName ?? ''],
|
||||||
|
iconActions: isOwned ? [(FontAwesomeIcons.penToSquare, _showEditDescriptionName)] : [],
|
||||||
|
);
|
||||||
|
} else if (_editDescriptionName == EditState.saving) {
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 4, horizontal: 0),
|
||||||
|
child: UI.box(
|
||||||
|
context: context,
|
||||||
|
padding: EdgeInsets.fromLTRB(16, 2, 4, 2),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(child: Center(child: FaIcon(FontAwesomeIcons.solidInputPipe, size: 18)), height: 43),
|
||||||
|
SizedBox(width: 16),
|
||||||
|
Expanded(child: SizedBox()),
|
||||||
|
SizedBox(width: 12),
|
||||||
|
SizedBox(width: 4),
|
||||||
|
Padding(padding: const EdgeInsets.all(8.0), child: SizedBox(width: 18, height: 18, child: CircularProgressIndicator())),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw 'Invalid EditDescriptionNameState: $_editDescriptionName';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showEditDisplayName() {
|
||||||
|
setState(() {
|
||||||
|
_ctrlDisplayName.text = _displayNameOverride ?? channelPreview?.displayName ?? '';
|
||||||
|
_editDisplayName = EditState.editing;
|
||||||
|
if (_editDescriptionName == EditState.editing) _editDescriptionName = EditState.none;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _saveDisplayName() async {
|
||||||
|
final userAcc = Provider.of<AppAuth>(context, listen: false);
|
||||||
|
|
||||||
|
final newName = _ctrlDisplayName.text;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setState(() {
|
||||||
|
_editDisplayName = EditState.saving;
|
||||||
|
});
|
||||||
|
|
||||||
|
final newChannel = await APIClient.updateChannel(userAcc, widget.channelID, displayName: newName);
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_editDisplayName = EditState.none;
|
||||||
|
_displayNameOverride = newChannel.channel.displayName;
|
||||||
|
});
|
||||||
|
|
||||||
|
widget.needsReload?.call();
|
||||||
|
} catch (exc, trace) {
|
||||||
|
ApplicationLog.error('Failed to save DisplayName: ' + exc.toString(), trace: trace);
|
||||||
|
Toaster.error("Error", 'Failed to save DisplayName');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showEditDescriptionName() {
|
||||||
|
setState(() {
|
||||||
|
_ctrlDescriptionName.text = _descriptionNameOverride ?? channelPreview?.descriptionName ?? '';
|
||||||
|
_editDescriptionName = EditState.editing;
|
||||||
|
if (_editDisplayName == EditState.editing) _editDisplayName = EditState.none;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _saveDescriptionName() async {
|
||||||
|
final userAcc = Provider.of<AppAuth>(context, listen: false);
|
||||||
|
|
||||||
|
final newName = _ctrlDescriptionName.text;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setState(() {
|
||||||
|
_editDescriptionName = EditState.saving;
|
||||||
|
});
|
||||||
|
|
||||||
|
final newChannel = await APIClient.updateChannel(userAcc, widget.channelID, descriptionName: newName);
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_editDescriptionName = EditState.none;
|
||||||
|
_descriptionNameOverride = newChannel.channel.descriptionName ?? '';
|
||||||
|
});
|
||||||
|
|
||||||
|
widget.needsReload?.call();
|
||||||
|
} catch (exc, trace) {
|
||||||
|
ApplicationLog.error('Failed to save DescriptionName: ' + exc.toString(), trace: trace);
|
||||||
|
Toaster.error("Error", 'Failed to save DescriptionName');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _subscribe() async {
|
||||||
|
final acc = AppAuth();
|
||||||
|
|
||||||
|
try {
|
||||||
|
var sub = await APIClient.subscribeToChannelbyID(acc, widget.channelID);
|
||||||
|
widget.needsReload?.call();
|
||||||
|
|
||||||
|
await _initStateAsync(false);
|
||||||
|
|
||||||
|
if (sub.confirmed) {
|
||||||
|
Toaster.success("Success", 'Subscribed to channel');
|
||||||
|
} else {
|
||||||
|
Toaster.success("Success", 'Requested subscription to channel');
|
||||||
|
}
|
||||||
|
} catch (exc, trace) {
|
||||||
|
Toaster.error("Error", 'Failed to subscribe to channel');
|
||||||
|
ApplicationLog.error('Failed to subscribe to channel: ' + exc.toString(), trace: trace);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _unsubscribe() async {
|
||||||
|
final acc = AppAuth();
|
||||||
|
|
||||||
|
if (subscription == null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await APIClient.deleteSubscription(acc, widget.channelID, subscription!.subscriptionID);
|
||||||
|
widget.needsReload?.call();
|
||||||
|
|
||||||
|
await _initStateAsync(false);
|
||||||
|
|
||||||
|
Toaster.success("Success", 'Unsubscribed from channel');
|
||||||
|
} catch (exc, trace) {
|
||||||
|
Toaster.error("Error", 'Failed to unsubscribe from channel');
|
||||||
|
ApplicationLog.error('Failed to unsubscribe from channel: ' + exc.toString(), trace: trace);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _cancelForeignSubscription(Subscription sub) async {
|
||||||
|
final acc = AppAuth();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await APIClient.unconfirmSubscription(acc, widget.channelID, subscription!.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, 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 _denyForeignSubscription(Subscription sub) async {
|
||||||
|
final acc = AppAuth();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await APIClient.deleteSubscription(acc, widget.channelID, subscription!.subscriptionID);
|
||||||
|
widget.needsReload?.call();
|
||||||
|
|
||||||
|
await _initStateAsync(false);
|
||||||
|
|
||||||
|
Toaster.success("Success", 'Subscription request succesfully denied');
|
||||||
|
} catch (exc, trace) {
|
||||||
|
Toaster.error("Error", 'Failed to deny subscription');
|
||||||
|
ApplicationLog.error('Failed to deny subscription: ' + exc.toString(), trace: trace);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatSubscriptionStatus(Subscription? subscription) {
|
||||||
|
if (subscription == null) {
|
||||||
|
return 'Not Subscribed';
|
||||||
|
} else if (subscription.confirmed) {
|
||||||
|
return 'Subscribed';
|
||||||
|
} else {
|
||||||
|
return 'Requested';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> _getSubscribeKey(AppAuth auth) async {
|
||||||
|
try {
|
||||||
|
await Future.delayed(const Duration(seconds: 0), () {}); // this is annoyingly important - otherwise we call setLoadingIndeterminate directly in initStat() and get an exception....
|
||||||
|
|
||||||
|
_incLoadingIndeterminateCounter(1);
|
||||||
|
|
||||||
|
var channel = await APIClient.getChannel(auth, widget.channelID);
|
||||||
|
|
||||||
|
//await Future.delayed(const Duration(seconds: 10), () {});
|
||||||
|
|
||||||
|
return channel.channel.subscribeKey;
|
||||||
|
} finally {
|
||||||
|
_incLoadingIndeterminateCounter(-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<(Subscription, UserPreview?)>> _listSubscriptions(AppAuth auth) async {
|
||||||
|
try {
|
||||||
|
await Future.delayed(const Duration(seconds: 0), () {}); // this is annoyingly important - otherwise we call setLoadingIndeterminate directly in initStat() and get an exception....
|
||||||
|
|
||||||
|
_incLoadingIndeterminateCounter(1);
|
||||||
|
|
||||||
|
var subs = await APIClient.getChannelSubscriptions(auth, widget.channelID);
|
||||||
|
|
||||||
|
var userMap = {for (var v in (await Future.wait(subs.map((e) => e.subscriberUserID).toSet().map((e) => APIClient.getUserPreview(auth, e)).toList()))) v.userID: v};
|
||||||
|
|
||||||
|
//await Future.delayed(const Duration(seconds: 10), () {});
|
||||||
|
|
||||||
|
return subs.map((e) => (e, userMap[e.subscriberUserID] ?? null)).toList();
|
||||||
|
} finally {
|
||||||
|
_incLoadingIndeterminateCounter(-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<UserPreview> _getOwner(AppAuth auth) async {
|
||||||
|
try {
|
||||||
|
await Future.delayed(const Duration(seconds: 0), () {}); // this is annoyingly important - otherwise we call setLoadingIndeterminate directly in initStat() and get an exception....
|
||||||
|
|
||||||
|
_incLoadingIndeterminateCounter(1);
|
||||||
|
|
||||||
|
final owner = APIClient.getUserPreview(auth, channelPreview!.ownerUserID);
|
||||||
|
|
||||||
|
//await Future.delayed(const Duration(seconds: 10), () {});
|
||||||
|
|
||||||
|
return owner;
|
||||||
|
} finally {
|
||||||
|
_incLoadingIndeterminateCounter(-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<(IconData, void Function())> _getForeignSubActions(Subscription sub) {
|
||||||
|
if (sub.confirmed) {
|
||||||
|
return [(FontAwesomeIcons.solidSquareXmark, () => _cancelForeignSubscription(sub))];
|
||||||
|
} else {
|
||||||
|
return [
|
||||||
|
(FontAwesomeIcons.solidSquareCheck, () => _confirmForeignSubscription(sub)),
|
||||||
|
(FontAwesomeIcons.solidSquareXmark, () => _denyForeignSubscription(sub)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _incLoadingIndeterminateCounter(int delta) {
|
||||||
|
setState(() {
|
||||||
|
_loadingIndeterminateCounter += delta;
|
||||||
|
AppBarState().setLoadingIndeterminate(_loadingIndeterminateCounter > 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@@ -1,4 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:simplecloudnotifier/state/application_log.dart';
|
||||||
|
import 'package:simplecloudnotifier/utils/notifier.dart';
|
||||||
import 'package:simplecloudnotifier/utils/toaster.dart';
|
import 'package:simplecloudnotifier/utils/toaster.dart';
|
||||||
import 'package:simplecloudnotifier/utils/ui.dart';
|
import 'package:simplecloudnotifier/utils/ui.dart';
|
||||||
|
|
||||||
@@ -52,6 +54,12 @@ class _DebugActionsPageState extends State<DebugActionsPage> {
|
|||||||
onPressed: _sendTokenToServer,
|
onPressed: _sendTokenToServer,
|
||||||
text: 'Send FCM Token to Server',
|
text: 'Send FCM Token to Server',
|
||||||
),
|
),
|
||||||
|
SizedBox(height: 20),
|
||||||
|
UI.button(
|
||||||
|
big: false,
|
||||||
|
onPressed: () => Notifier.showLocalNotification('', 'TEST_CHANNEL', "Test Channel", "Channel for testing", "Hello World", "Local Notification test", null),
|
||||||
|
text: 'Show local notification',
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@@ -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),
|
||||||
|
@@ -30,7 +30,6 @@ class _DebugMainPageState extends State<DebugMainPage> {
|
|||||||
return SCNScaffold(
|
return SCNScaffold(
|
||||||
title: 'Debug',
|
title: 'Debug',
|
||||||
showSearch: false,
|
showSearch: false,
|
||||||
showDebug: false,
|
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
@@ -61,7 +60,7 @@ class _DebugMainPageState extends State<DebugMainPage> {
|
|||||||
ButtonSegment<DebugMainPageSubPage>(value: DebugMainPageSubPage.logs, icon: Icon(FontAwesomeIcons.solidFileLines, size: 14)),
|
ButtonSegment<DebugMainPageSubPage>(value: DebugMainPageSubPage.logs, icon: Icon(FontAwesomeIcons.solidFileLines, 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},
|
||||||
|
@@ -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/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
|
||||||
@@ -36,9 +41,10 @@ class _DebugPersistencePageState extends State<DebugPersistencePage> {
|
|||||||
_buildSharedPrefCard(context),
|
_buildSharedPrefCard(context),
|
||||||
_buildHiveCard(context, () => Hive.box<SCNRequest>('scn-requests'), 'scn-requests'),
|
_buildHiveCard(context, () => Hive.box<SCNRequest>('scn-requests'), 'scn-requests'),
|
||||||
_buildHiveCard(context, () => Hive.box<SCNLog>('scn-logs'), 'scn-logs'),
|
_buildHiveCard(context, () => Hive.box<SCNLog>('scn-logs'), 'scn-logs'),
|
||||||
_buildHiveCard(context, () => Hive.box<Message>('scn-message-cache'), 'scn-message-cache'),
|
_buildHiveCard(context, () => Hive.box<SCNMessage>('scn-message-cache'), 'scn-message-cache'),
|
||||||
_buildHiveCard(context, () => Hive.box<Channel>('scn-channel-cache'), 'scn-channel-cache'),
|
_buildHiveCard(context, () => Hive.box<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),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -71,7 +77,7 @@ class _DebugPersistencePageState extends State<DebugPersistencePage> {
|
|||||||
padding: const EdgeInsets.all(8.0),
|
padding: const EdgeInsets.all(8.0),
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navi.push(context, () => DebugHiveBoxPage(boxName: boxname, box: Hive.box<FBMessage>(boxname)));
|
Navi.push(context, () => DebugHiveBoxPage(boxName: boxname, box: boxFunc()));
|
||||||
},
|
},
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
@@ -85,4 +91,25 @@ class _DebugPersistencePageState extends State<DebugPersistencePage> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildFailureLogCard(BuildContext context, Directory dir) {
|
||||||
|
return Card.outlined(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(8.0),
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
Navi.push(context, () => DebugFailureLogsPage(dir: dir.path));
|
||||||
|
},
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
SizedBox(width: 30, child: Text('')),
|
||||||
|
Expanded(child: Text('Failure [/${path.basename(dir.path)}/]', style: TextStyle(fontWeight: FontWeight.bold), textAlign: TextAlign.center)),
|
||||||
|
SizedBox(width: 40, child: Text("${dir.listSync().length}", textAlign: TextAlign.end)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -0,0 +1,63 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:simplecloudnotifier/components/layout/scaffold.dart';
|
||||||
|
import 'package:simplecloudnotifier/types/immediate_future.dart';
|
||||||
|
|
||||||
|
class DebugFailureLogFilePage extends StatefulWidget {
|
||||||
|
final String path;
|
||||||
|
|
||||||
|
DebugFailureLogFilePage({required this.path}) {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<DebugFailureLogFilePage> createState() => _DebugFailureLogFilePageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DebugFailureLogFilePageState extends State<DebugFailureLogFilePage> {
|
||||||
|
ImmediateFuture<String>? _futureContent;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
|
||||||
|
_futureContent = ImmediateFuture.ofFuture(new File(this.widget.path).readAsString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SCNScaffold(
|
||||||
|
title: 'FailureLog',
|
||||||
|
showSearch: false,
|
||||||
|
child: () {
|
||||||
|
if (_futureContent == null) {
|
||||||
|
return Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
return FutureBuilder(
|
||||||
|
future: _futureContent!.future,
|
||||||
|
builder: ((context, snapshot) {
|
||||||
|
if (_futureContent?.value != null) {
|
||||||
|
return _buildContent(context, _futureContent!.value!);
|
||||||
|
} else if (snapshot.connectionState == ConnectionState.done && snapshot.hasError) {
|
||||||
|
return Text('Error: ${snapshot.error}'); //TODO better error display
|
||||||
|
} else if (snapshot.connectionState == ConnectionState.done) {
|
||||||
|
return _buildContent(context, snapshot.data!);
|
||||||
|
} else {
|
||||||
|
return Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildContent(BuildContext context, String value) {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(8.0),
|
||||||
|
child: Text(value, style: TextStyle(fontFamily: "monospace")),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
86
flutter/lib/pages/debug/debug_persistence_failurelogs.dart
Normal file
86
flutter/lib/pages/debug/debug_persistence_failurelogs.dart
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
@@ -16,7 +16,6 @@ class DebugHiveBoxPage extends StatelessWidget {
|
|||||||
return SCNScaffold(
|
return SCNScaffold(
|
||||||
title: 'Hive: ' + boxName,
|
title: 'Hive: ' + boxName,
|
||||||
showSearch: false,
|
showSearch: false,
|
||||||
showDebug: false,
|
|
||||||
child: ListView.separated(
|
child: ListView.separated(
|
||||||
itemCount: box.length,
|
itemCount: box.length,
|
||||||
itemBuilder: (context, listIndex) {
|
itemBuilder: (context, listIndex) {
|
||||||
@@ -24,8 +23,9 @@ class DebugHiveBoxPage extends StatelessWidget {
|
|||||||
onTap: () {
|
onTap: () {
|
||||||
Navi.push(context, () => DebugHiveEntryPage(value: box.getAt(listIndex)!));
|
Navi.push(context, () => DebugHiveEntryPage(value: box.getAt(listIndex)!));
|
||||||
},
|
},
|
||||||
child: ListTile(
|
child: Container(
|
||||||
title: Text(box.getAt(listIndex).toString(), style: TextStyle(fontWeight: FontWeight.bold)),
|
padding: EdgeInsets.fromLTRB(8, 4, 8, 4),
|
||||||
|
child: Text(box.getAt(listIndex).toString(), style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12)),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
@@ -13,11 +13,13 @@ class DebugHiveEntryPage extends StatelessWidget {
|
|||||||
return SCNScaffold(
|
return SCNScaffold(
|
||||||
title: 'HiveEntry',
|
title: 'HiveEntry',
|
||||||
showSearch: false,
|
showSearch: false,
|
||||||
showDebug: false,
|
|
||||||
child: ListView.separated(
|
child: ListView.separated(
|
||||||
itemCount: fields.length,
|
itemCount: fields.length,
|
||||||
itemBuilder: (context, listIndex) {
|
itemBuilder: (context, listIndex) {
|
||||||
return ListTile(
|
return ListTile(
|
||||||
|
dense: true,
|
||||||
|
contentPadding: EdgeInsets.fromLTRB(8, 0, 8, 0),
|
||||||
|
visualDensity: VisualDensity(horizontal: 0, vertical: -4),
|
||||||
title: Text(fields[listIndex].$1, style: TextStyle(fontWeight: FontWeight.bold)),
|
title: Text(fields[listIndex].$1, style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
subtitle: Text(fields[listIndex].$2, style: TextStyle(fontFamily: "monospace")),
|
subtitle: Text(fields[listIndex].$2, style: TextStyle(fontFamily: "monospace")),
|
||||||
);
|
);
|
||||||
|
@@ -13,7 +13,6 @@ class DebugSharedPrefPage extends StatelessWidget {
|
|||||||
return SCNScaffold(
|
return SCNScaffold(
|
||||||
title: 'SharedPreferences',
|
title: 'SharedPreferences',
|
||||||
showSearch: false,
|
showSearch: false,
|
||||||
showDebug: false,
|
|
||||||
child: ListView.separated(
|
child: ListView.separated(
|
||||||
itemCount: sharedPref.getKeys().length,
|
itemCount: sharedPref.getKeys().length,
|
||||||
itemBuilder: (context, listIndex) {
|
itemBuilder: (context, listIndex) {
|
||||||
|
@@ -16,7 +16,6 @@ class DebugRequestViewPage extends StatelessWidget {
|
|||||||
return SCNScaffold(
|
return SCNScaffold(
|
||||||
title: 'Request',
|
title: 'Request',
|
||||||
showSearch: false,
|
showSearch: false,
|
||||||
showDebug: false,
|
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(8.0),
|
padding: const EdgeInsets.all(8.0),
|
||||||
@@ -61,6 +60,7 @@ class DebugRequestViewPage extends StatelessWidget {
|
|||||||
onPressed: () {
|
onPressed: () {
|
||||||
Clipboard.setData(new ClipboardData(text: title));
|
Clipboard.setData(new ClipboardData(text: title));
|
||||||
Toaster.info("Clipboard", 'Copied text to Clipboard');
|
Toaster.info("Clipboard", 'Copied text to Clipboard');
|
||||||
|
print('================= [CLIPBOARD] =================\n${title}\n================= [/CLIPBOARD] =================');
|
||||||
},
|
},
|
||||||
icon: FontAwesomeIcons.copy,
|
icon: FontAwesomeIcons.copy,
|
||||||
),
|
),
|
||||||
|
36
flutter/lib/pages/message_list/message_filter_chiplet.dart
Normal file
36
flutter/lib/pages/message_list/message_filter_chiplet.dart
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import 'package:flutter/src/widgets/icon_data.dart';
|
||||||
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||||
|
|
||||||
|
enum MessageFilterChipletType {
|
||||||
|
search,
|
||||||
|
channel,
|
||||||
|
sender,
|
||||||
|
timeRange,
|
||||||
|
priority,
|
||||||
|
sendkey,
|
||||||
|
}
|
||||||
|
|
||||||
|
class MessageFilterChiplet {
|
||||||
|
final String label; // display value
|
||||||
|
final dynamic value; // search/api value
|
||||||
|
final MessageFilterChipletType type;
|
||||||
|
|
||||||
|
MessageFilterChiplet({required this.label, required this.value, required this.type});
|
||||||
|
|
||||||
|
IconData? icon() {
|
||||||
|
switch (type) {
|
||||||
|
case MessageFilterChipletType.search:
|
||||||
|
return FontAwesomeIcons.magnifyingGlass;
|
||||||
|
case MessageFilterChipletType.channel:
|
||||||
|
return FontAwesomeIcons.snake;
|
||||||
|
case MessageFilterChipletType.sender:
|
||||||
|
return FontAwesomeIcons.signature;
|
||||||
|
case MessageFilterChipletType.timeRange:
|
||||||
|
return FontAwesomeIcons.timer;
|
||||||
|
case MessageFilterChipletType.priority:
|
||||||
|
return FontAwesomeIcons.bolt;
|
||||||
|
case MessageFilterChipletType.sendkey:
|
||||||
|
return FontAwesomeIcons.gearCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -1,15 +1,18 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hive_flutter/hive_flutter.dart';
|
|
||||||
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
|
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/models/message.dart';
|
import 'package:simplecloudnotifier/models/scn_message.dart';
|
||||||
|
import 'package:simplecloudnotifier/pages/message_list/message_filter_chiplet.dart';
|
||||||
import 'package:simplecloudnotifier/pages/message_view/message_view.dart';
|
import 'package:simplecloudnotifier/pages/message_view/message_view.dart';
|
||||||
|
import 'package:simplecloudnotifier/settings/app_settings.dart';
|
||||||
import 'package:simplecloudnotifier/state/app_bar_state.dart';
|
import 'package:simplecloudnotifier/state/app_bar_state.dart';
|
||||||
|
import 'package:simplecloudnotifier/state/app_events.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';
|
||||||
import 'package:simplecloudnotifier/pages/message_list/message_list_item.dart';
|
import 'package:simplecloudnotifier/pages/message_list/message_list_item.dart';
|
||||||
|
import 'package:simplecloudnotifier/state/scn_data_cache.dart';
|
||||||
import 'package:simplecloudnotifier/utils/navi.dart';
|
import 'package:simplecloudnotifier/utils/navi.dart';
|
||||||
|
|
||||||
class MessageListPage extends StatefulWidget {
|
class MessageListPage extends StatefulWidget {
|
||||||
@@ -17,28 +20,28 @@ class MessageListPage extends StatefulWidget {
|
|||||||
|
|
||||||
final bool isVisiblePage;
|
final bool isVisiblePage;
|
||||||
|
|
||||||
//TODO reload on switch to tab
|
|
||||||
//TODO reload on app to foreground
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<MessageListPage> createState() => _MessageListPageState();
|
State<MessageListPage> createState() => _MessageListPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MessageListPageState extends State<MessageListPage> with RouteAware {
|
class _MessageListPageState extends State<MessageListPage> with RouteAware {
|
||||||
static const _pageSize = 128;
|
|
||||||
|
|
||||||
late final AppLifecycleListener _lifecyleListener;
|
late final AppLifecycleListener _lifecyleListener;
|
||||||
|
|
||||||
PagingController<String, Message> _pagingController = PagingController.fromValue(PagingState(nextPageKey: null, itemList: [], error: null), firstPageKey: '@start');
|
PagingController<String, SCNMessage> _pagingController = PagingController.fromValue(PagingState(nextPageKey: null, itemList: [], error: null), firstPageKey: '@start');
|
||||||
|
|
||||||
Map<String, Channel>? _channels = null;
|
Map<String, Channel>? _channels = null;
|
||||||
|
|
||||||
bool _isInitialized = false;
|
bool _isInitialized = false;
|
||||||
|
|
||||||
|
List<MessageFilterChiplet> _filterChiplets = [];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
||||||
|
AppEvents().subscribeFilterListener(_onAddFilter);
|
||||||
|
AppEvents().subscribeMessageReceivedListener(_onMessageReceivedViaNotification);
|
||||||
|
|
||||||
_pagingController.addPageRequestListener(_fetchPage);
|
_pagingController.addPageRequestListener(_fetchPage);
|
||||||
|
|
||||||
if (widget.isVisiblePage && !_isInitialized) _realInitState();
|
if (widget.isVisiblePage && !_isInitialized) _realInitState();
|
||||||
@@ -64,18 +67,15 @@ class _MessageListPageState extends State<MessageListPage> with RouteAware {
|
|||||||
void _realInitState() {
|
void _realInitState() {
|
||||||
ApplicationLog.debug('MessageListPage::_realInitState');
|
ApplicationLog.debug('MessageListPage::_realInitState');
|
||||||
|
|
||||||
final chnCache = Hive.box<Channel>('scn-channel-cache');
|
if (SCNDataCache().hasMessagesAndChannels()) {
|
||||||
final msgCache = Hive.box<Message>('scn-message-cache');
|
|
||||||
|
|
||||||
if (chnCache.isNotEmpty && msgCache.isNotEmpty) {
|
|
||||||
// ==== Use cache values - and refresh in background
|
// ==== Use cache values - and refresh in background
|
||||||
|
|
||||||
_channels = <String, Channel>{for (var v in chnCache.values) v.channelID: v};
|
_channels = SCNDataCache().getChannelMap();
|
||||||
|
|
||||||
final cacheMessages = msgCache.values.toList();
|
//TODO this is not 100% correct - the message-cache contains (which is right!) all messages, even from unsubscribed channels
|
||||||
cacheMessages.sort((a, b) => -1 * a.timestamp.compareTo(b.timestamp));
|
//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: cacheMessages, error: null);
|
_pagingController.value = PagingState(nextPageKey: null, itemList: SCNDataCache().getMessagesSorted(), error: null);
|
||||||
|
|
||||||
_backgroundRefresh(true);
|
_backgroundRefresh(true);
|
||||||
} else {
|
} else {
|
||||||
@@ -95,6 +95,8 @@ class _MessageListPageState extends State<MessageListPage> with RouteAware {
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
ApplicationLog.debug('MessageListPage::dispose');
|
ApplicationLog.debug('MessageListPage::dispose');
|
||||||
|
AppEvents().unsubscribeFilterListener(_onAddFilter);
|
||||||
|
AppEvents().unsubscribeMessageReceivedListener(_onMessageReceivedViaNotification);
|
||||||
Navi.modalRouteObserver.unsubscribe(this);
|
Navi.modalRouteObserver.unsubscribe(this);
|
||||||
_pagingController.dispose();
|
_pagingController.dispose();
|
||||||
_lifecyleListener.dispose();
|
_lifecyleListener.dispose();
|
||||||
@@ -108,17 +110,22 @@ class _MessageListPageState extends State<MessageListPage> with RouteAware {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void didPopNext() {
|
void didPopNext() {
|
||||||
ApplicationLog.debug('[MessageList::RouteObserver] --> didPopNext (will background-refresh)');
|
if (AppSettings().backgroundRefreshMessageListOnPop) {
|
||||||
_backgroundRefresh(false);
|
ApplicationLog.debug('[MessageList::RouteObserver] --> didPopNext (will background-refresh)');
|
||||||
|
_backgroundRefresh(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onLifecycleResume() {
|
void _onLifecycleResume() {
|
||||||
ApplicationLog.debug('[MessageList::_onLifecycleResume] --> (will background-refresh)');
|
if (AppSettings().alwaysBackgroundRefreshMessageListOnLifecycleResume && widget.isVisiblePage) {
|
||||||
_backgroundRefresh(false);
|
ApplicationLog.debug('[MessageList::_onLifecycleResume] --> (will background-refresh)');
|
||||||
|
_backgroundRefresh(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _fetchPage(String thisPageToken) async {
|
Future<void> _fetchPage(String thisPageToken) async {
|
||||||
final acc = Provider.of<AppAuth>(context, listen: false);
|
final acc = Provider.of<AppAuth>(context, listen: false);
|
||||||
|
final cfg = Provider.of<AppSettings>(context, listen: false);
|
||||||
|
|
||||||
ApplicationLog.debug('Start MessageList::_pagingController::_fetchPage [ ${thisPageToken} ]');
|
ApplicationLog.debug('Start MessageList::_pagingController::_fetchPage [ ${thisPageToken} ]');
|
||||||
|
|
||||||
@@ -132,12 +139,12 @@ class _MessageListPageState extends State<MessageListPage> with RouteAware {
|
|||||||
final channels = await APIClient.getChannelList(acc, ChannelSelector.allAny);
|
final channels = await APIClient.getChannelList(acc, ChannelSelector.allAny);
|
||||||
_channels = <String, Channel>{for (var v in channels) v.channel.channelID: v.channel};
|
_channels = <String, Channel>{for (var v in channels) v.channel.channelID: v.channel};
|
||||||
|
|
||||||
_setChannelCache(channels); // no await
|
SCNDataCache().setChannelCache(channels); // no await
|
||||||
}
|
}
|
||||||
|
|
||||||
final (npt, newItems) = await APIClient.getMessageList(acc, thisPageToken, pageSize: _pageSize);
|
final (npt, newItems) = await APIClient.getMessageList(acc, thisPageToken, pageSize: cfg.messagePageSize, filter: _getFilter());
|
||||||
|
|
||||||
_addToMessageCache(newItems); // no await
|
SCNDataCache().addToMessageCache(newItems); // no await
|
||||||
|
|
||||||
ApplicationLog.debug('Finished MessageList::_pagingController::_fetchPage [ ${newItems.length} items and npt: ${thisPageToken} --> ${npt} ]');
|
ApplicationLog.debug('Finished MessageList::_pagingController::_fetchPage [ ${newItems.length} items and npt: ${thisPageToken} --> ${npt} ]');
|
||||||
|
|
||||||
@@ -154,6 +161,7 @@ class _MessageListPageState extends State<MessageListPage> with RouteAware {
|
|||||||
|
|
||||||
Future<void> _backgroundRefresh(bool fullReplaceState) async {
|
Future<void> _backgroundRefresh(bool fullReplaceState) async {
|
||||||
final acc = Provider.of<AppAuth>(context, listen: false);
|
final acc = Provider.of<AppAuth>(context, listen: false);
|
||||||
|
final cfg = Provider.of<AppSettings>(context, listen: false);
|
||||||
|
|
||||||
ApplicationLog.debug('Start background refresh of message list (fullReplaceState: $fullReplaceState)');
|
ApplicationLog.debug('Start background refresh of message list (fullReplaceState: $fullReplaceState)');
|
||||||
|
|
||||||
@@ -167,12 +175,12 @@ class _MessageListPageState extends State<MessageListPage> with RouteAware {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_channels = <String, Channel>{for (var v in channels) v.channel.channelID: v.channel};
|
_channels = <String, Channel>{for (var v in channels) v.channel.channelID: v.channel};
|
||||||
});
|
});
|
||||||
_setChannelCache(channels); // no await
|
SCNDataCache().setChannelCache(channels); // no await
|
||||||
}
|
}
|
||||||
|
|
||||||
final (npt, newItems) = await APIClient.getMessageList(acc, '@start', pageSize: _pageSize);
|
final (npt, newItems) = await APIClient.getMessageList(acc, '@start', pageSize: cfg.messagePageSize);
|
||||||
|
|
||||||
_addToMessageCache(newItems); // no await
|
SCNDataCache().addToMessageCache(newItems); // no await
|
||||||
|
|
||||||
if (fullReplaceState) {
|
if (fullReplaceState) {
|
||||||
// fully replace/reset state
|
// fully replace/reset state
|
||||||
@@ -221,49 +229,106 @@ class _MessageListPageState extends State<MessageListPage> with RouteAware {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: EdgeInsets.fromLTRB(8, 4, 8, 4),
|
padding: EdgeInsets.fromLTRB(8, 4, 8, 4),
|
||||||
child: RefreshIndicator(
|
child: Column(
|
||||||
onRefresh: () => Future.sync(
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
() => _pagingController.refresh(),
|
children: [
|
||||||
),
|
if (_filterChiplets.isNotEmpty)
|
||||||
child: PagedListView<String, Message>(
|
Wrap(
|
||||||
pagingController: _pagingController,
|
alignment: WrapAlignment.start,
|
||||||
builderDelegate: PagedChildBuilderDelegate<Message>(
|
spacing: 5.0,
|
||||||
itemBuilder: (context, item, index) => MessageListItem(
|
children: [
|
||||||
message: item,
|
for (var chiplet in _filterChiplets) _buildFilterChip(context, chiplet),
|
||||||
allChannels: _channels ?? {},
|
],
|
||||||
onPressed: () {
|
),
|
||||||
Navi.push(context, () => MessageViewPage(message: item));
|
Expanded(
|
||||||
},
|
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,)));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _setChannelCache(List<ChannelWithSubscription> channels) async {
|
Widget _buildFilterChip(BuildContext context, MessageFilterChiplet chiplet) {
|
||||||
final cache = Hive.box<Channel>('scn-channel-cache');
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(0, 2, 0, 2),
|
||||||
if (cache.length != channels.length) await cache.clear();
|
child: InputChip(
|
||||||
|
avatar: Icon(chiplet.icon()),
|
||||||
for (var chn in channels) await cache.put(chn.channel.channelID, chn.channel);
|
label: Text(chiplet.label),
|
||||||
|
onDeleted: () => _onRemFilter(chiplet),
|
||||||
|
onPressed: () {/* TODO idk what to do here ? */},
|
||||||
|
visualDensity: VisualDensity(horizontal: -4, vertical: -4),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _addToMessageCache(List<Message> newItems) async {
|
void _onAddFilter(List<MessageFilterChipletType> remTypeList, List<MessageFilterChiplet> chiplets) {
|
||||||
final cache = Hive.box<Message>('scn-message-cache');
|
setState(() {
|
||||||
|
final remTypes = remTypeList.toSet();
|
||||||
|
|
||||||
for (var msg in newItems) await cache.put(msg.messageID, msg);
|
_filterChiplets = _filterChiplets.where((element) => !remTypes.contains(element.type)).toList() + chiplets;
|
||||||
|
|
||||||
// delete all but the newest 128 messages
|
_pagingController.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (cache.length < _pageSize) return;
|
void _onRemFilter(MessageFilterChiplet chiplet) {
|
||||||
|
setState(() {
|
||||||
|
_filterChiplets.remove(chiplet);
|
||||||
|
|
||||||
final allValues = cache.values.toList();
|
_pagingController.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
allValues.sort((a, b) => -1 * a.timestamp.compareTo(b.timestamp));
|
void _onMessageReceivedViaNotification(SCNMessage msg) {
|
||||||
|
setState(() {
|
||||||
|
_pagingController.itemList = [msg] + (_pagingController.itemList ?? []);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
for (var val in allValues.sublist(_pageSize)) {
|
MessageFilter _getFilter() {
|
||||||
await cache.delete(val.messageID);
|
var filter = MessageFilter();
|
||||||
|
|
||||||
|
var chipletsChannel = _filterChiplets.where((p) => p.type == MessageFilterChipletType.channel).toList();
|
||||||
|
if (chipletsChannel.isNotEmpty) {
|
||||||
|
filter.channelIDs = chipletsChannel.map((p) => p.value as String).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var chipletsSearch = _filterChiplets.where((p) => p.type == MessageFilterChipletType.search).toList();
|
||||||
|
if (chipletsSearch.isNotEmpty) {
|
||||||
|
filter.searchFilter = chipletsSearch.map((p) => p.value as String).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
var chipletsKeyTokens = _filterChiplets.where((p) => p.type == MessageFilterChipletType.sendkey).toList();
|
||||||
|
if (chipletsKeyTokens.isNotEmpty) {
|
||||||
|
filter.usedKeys = chipletsKeyTokens.map((p) => p.value as String).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
var chipletPriority = _filterChiplets.where((p) => p.type == MessageFilterChipletType.priority).toList();
|
||||||
|
if (chipletPriority.isNotEmpty) {
|
||||||
|
filter.priority = chipletPriority.map((p) => p.value as int).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
var chipletSender = _filterChiplets.where((p) => p.type == MessageFilterChipletType.sender).toList();
|
||||||
|
if (chipletSender.isNotEmpty) {
|
||||||
|
filter.senderNames = chipletSender.map((p) => p.value as String).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
return filter;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -3,7 +3,7 @@ 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:simplecloudnotifier/models/channel.dart';
|
import 'package:simplecloudnotifier/models/channel.dart';
|
||||||
import 'package:simplecloudnotifier/models/message.dart';
|
import 'package:simplecloudnotifier/models/scn_message.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:simplecloudnotifier/utils/ui.dart';
|
import 'package:simplecloudnotifier/utils/ui.dart';
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ class MessageListItem extends StatelessWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
final Message message;
|
final SCNMessage message;
|
||||||
final Map<String, Channel> allChannels;
|
final Map<String, Channel> allChannels;
|
||||||
final Null Function() onPressed;
|
final Null Function() onPressed;
|
||||||
|
|
||||||
@@ -176,11 +176,11 @@ class MessageListItem extends StatelessWidget {
|
|||||||
return v;
|
return v;
|
||||||
}
|
}
|
||||||
|
|
||||||
String resolveChannelName(Message message) {
|
String resolveChannelName(SCNMessage message) {
|
||||||
return allChannels[message.channelID]?.displayName ?? message.channelInternalName;
|
return allChannels[message.channelID]?.displayName ?? message.channelInternalName;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool showChannel(Message message) {
|
bool showChannel(SCNMessage message) {
|
||||||
return message.channelInternalName != 'main';
|
return message.channelInternalName != 'main';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -8,36 +8,49 @@ import 'package:simplecloudnotifier/api/api_client.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/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/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/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';
|
||||||
|
|
||||||
class MessageViewPage extends StatefulWidget {
|
class MessageViewPage extends StatefulWidget {
|
||||||
const MessageViewPage({super.key, required this.message});
|
const MessageViewPage({
|
||||||
|
super.key,
|
||||||
|
required this.messageID,
|
||||||
|
required this.preloadedData,
|
||||||
|
});
|
||||||
|
|
||||||
final Message message; // Potentially trimmed
|
final String messageID; // Potentially trimmed
|
||||||
|
final (SCNMessage,)? preloadedData; // Message is potentially trimmed, whole object is potentially null
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<MessageViewPage> createState() => _MessageViewPageState();
|
State<MessageViewPage> createState() => _MessageViewPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MessageViewPageState extends State<MessageViewPage> {
|
class _MessageViewPageState extends State<MessageViewPage> {
|
||||||
late Future<(Message, ChannelPreview, KeyTokenPreview, UserPreview)>? mainFuture;
|
late Future<(SCNMessage, ChannelPreview, KeyTokenPreview, UserPreview)>? mainFuture;
|
||||||
(Message, ChannelPreview, KeyTokenPreview, UserPreview)? mainFutureSnapshot = null;
|
(SCNMessage, ChannelPreview, KeyTokenPreview, UserPreview)? mainFutureSnapshot = null;
|
||||||
static final _dateFormat = DateFormat('yyyy-MM-dd kk:mm');
|
static final _dateFormat = DateFormat('yyyy-MM-dd kk:mm');
|
||||||
|
|
||||||
bool _monospaceMode = false;
|
bool _monospaceMode = false;
|
||||||
|
|
||||||
|
SCNMessage? message = null;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
if (widget.preloadedData != null) {
|
||||||
|
message = widget.preloadedData!.$1;
|
||||||
|
}
|
||||||
|
|
||||||
mainFuture = fetchData();
|
mainFuture = fetchData();
|
||||||
super.initState();
|
super.initState();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<(Message, ChannelPreview, KeyTokenPreview, UserPreview)> fetchData() async {
|
Future<(SCNMessage, ChannelPreview, KeyTokenPreview, UserPreview)> fetchData() async {
|
||||||
try {
|
try {
|
||||||
await Future.delayed(const Duration(seconds: 0), () {}); // this is annoyingly important - otherwise we call setLoadingIndeterminate directly in initStat() and get an exception....
|
await Future.delayed(const Duration(seconds: 0), () {}); // this is annoyingly important - otherwise we call setLoadingIndeterminate directly in initStat() and get an exception....
|
||||||
|
|
||||||
@@ -45,7 +58,7 @@ class _MessageViewPageState extends State<MessageViewPage> {
|
|||||||
|
|
||||||
final acc = Provider.of<AppAuth>(context, listen: false);
|
final acc = Provider.of<AppAuth>(context, listen: false);
|
||||||
|
|
||||||
final msg = await APIClient.getMessage(acc, widget.message.messageID);
|
final msg = await APIClient.getMessage(acc, widget.messageID);
|
||||||
|
|
||||||
final fut_chn = APIClient.getChannelPreview(acc, msg.channelID);
|
final fut_chn = APIClient.getChannelPreview(acc, msg.channelID);
|
||||||
final fut_key = APIClient.getKeyTokenPreview(acc, msg.usedKeyID);
|
final fut_key = APIClient.getKeyTokenPreview(acc, msg.usedKeyID);
|
||||||
@@ -79,7 +92,7 @@ class _MessageViewPageState extends State<MessageViewPage> {
|
|||||||
showSearch: false,
|
showSearch: false,
|
||||||
showShare: true,
|
showShare: true,
|
||||||
onShare: _share,
|
onShare: _share,
|
||||||
child: FutureBuilder<(Message, ChannelPreview, KeyTokenPreview, UserPreview)>(
|
child: FutureBuilder<(SCNMessage, ChannelPreview, KeyTokenPreview, UserPreview)>(
|
||||||
future: mainFuture,
|
future: mainFuture,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
if (snapshot.hasData) {
|
if (snapshot.hasData) {
|
||||||
@@ -87,8 +100,8 @@ class _MessageViewPageState extends State<MessageViewPage> {
|
|||||||
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 Center(child: Text('${snapshot.error}')); //TODO nice error page
|
||||||
} else if (!widget.message.trimmed) {
|
} else if (message != null && !this.message!.trimmed) {
|
||||||
return _buildMessageView(context, widget.message, null, null, null);
|
return _buildMessageView(context, this.message!, null, null, null);
|
||||||
} else {
|
} else {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
@@ -98,7 +111,9 @@ class _MessageViewPageState extends State<MessageViewPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _share() async {
|
void _share() async {
|
||||||
var msg = widget.message;
|
if (this.message == null) return;
|
||||||
|
|
||||||
|
var msg = this.message!;
|
||||||
if (mainFutureSnapshot != null) {
|
if (mainFutureSnapshot != null) {
|
||||||
(msg, _, _, _) = mainFutureSnapshot!;
|
(msg, _, _, _) = mainFutureSnapshot!;
|
||||||
}
|
}
|
||||||
@@ -118,7 +133,7 @@ class _MessageViewPageState extends State<MessageViewPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildMessageView(BuildContext context, Message message, ChannelPreview? channel, KeyTokenPreview? token, UserPreview? user) {
|
Widget _buildMessageView(BuildContext context, SCNMessage message, ChannelPreview? channel, KeyTokenPreview? token, UserPreview? user) {
|
||||||
final userAccUserID = context.select<AppAuth, String?>((v) => v.userID);
|
final userAccUserID = context.select<AppAuth, String?>((v) => v.userID);
|
||||||
|
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
@@ -131,12 +146,58 @@ class _MessageViewPageState extends State<MessageViewPage> {
|
|||||||
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) _buildMetaCard(context, FontAwesomeIcons.solidSignature, 'Sender', [message.senderName!], () => {/*TODO*/}),
|
if (message.senderName != null)
|
||||||
_buildMetaCard(context, FontAwesomeIcons.solidGearCode, 'KeyToken', [message.usedKeyID, if (token != null) token.name], () => {/*TODO*/}),
|
UI.metaCard(
|
||||||
_buildMetaCard(context, FontAwesomeIcons.solidIdCardClip, 'MessageID', [message.messageID, if (message.userMessageID != null) message.userMessageID!], null),
|
context: context,
|
||||||
_buildMetaCard(context, FontAwesomeIcons.solidSnake, 'Channel', [message.channelID, channel?.displayName ?? message.channelInternalName], () => {/*TODO*/}),
|
icon: FontAwesomeIcons.solidSignature,
|
||||||
_buildMetaCard(context, FontAwesomeIcons.solidTimer, 'Timestamp', [message.timestamp], null),
|
title: 'Sender',
|
||||||
_buildMetaCard(context, FontAwesomeIcons.solidUser, 'User', [if (user != null) user.userID, if (user?.username != null) user!.username!], () => {/*TODO*/}), //TODO
|
values: [message.senderName!],
|
||||||
|
mainAction: () => {/*TODO*/},
|
||||||
|
),
|
||||||
|
UI.metaCard(
|
||||||
|
context: context,
|
||||||
|
icon: FontAwesomeIcons.solidGearCode,
|
||||||
|
title: 'KeyToken',
|
||||||
|
values: [message.usedKeyID, token?.name ?? '...'],
|
||||||
|
mainAction: () => {/*TODO*/},
|
||||||
|
),
|
||||||
|
UI.metaCard(
|
||||||
|
context: context,
|
||||||
|
icon: FontAwesomeIcons.solidIdCardClip,
|
||||||
|
title: 'MessageID',
|
||||||
|
values: [message.messageID, message.userMessageID ?? ''],
|
||||||
|
),
|
||||||
|
UI.metaCard(
|
||||||
|
context: context,
|
||||||
|
icon: FontAwesomeIcons.solidSnake,
|
||||||
|
title: 'Channel',
|
||||||
|
values: [message.channelID, channel?.displayName ?? message.channelInternalName],
|
||||||
|
mainAction: (channel != null)
|
||||||
|
? () {
|
||||||
|
Navi.push(context, () => ChannelViewPage(channelID: channel.channelID, preloadedData: null, needsReload: null));
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
UI.metaCard(
|
||||||
|
context: context,
|
||||||
|
icon: FontAwesomeIcons.solidTimer,
|
||||||
|
title: 'Timestamp',
|
||||||
|
values: [message.timestamp],
|
||||||
|
),
|
||||||
|
UI.metaCard(
|
||||||
|
context: context,
|
||||||
|
icon: FontAwesomeIcons.solidUser,
|
||||||
|
title: 'User',
|
||||||
|
values: [user?.userID ?? '...', user?.username ?? ''],
|
||||||
|
mainAction: () => {/*TODO*/},
|
||||||
|
),
|
||||||
|
UI.metaCard(
|
||||||
|
context: context,
|
||||||
|
icon: FontAwesomeIcons.solidBolt,
|
||||||
|
title: 'Priority',
|
||||||
|
values: [_prettyPrintPriority(message.priority)],
|
||||||
|
mainAction: () => {/*TODO*/},
|
||||||
|
),
|
||||||
if (message.senderUserID == userAccUserID) UI.button(text: "Delete Message", onPressed: () {/*TODO*/}, color: Colors.red[900]),
|
if (message.senderUserID == userAccUserID) UI.button(text: "Delete Message", onPressed: () {/*TODO*/}, color: Colors.red[900]),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -144,11 +205,11 @@ class _MessageViewPageState extends State<MessageViewPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _resolveChannelName(ChannelPreview? channel, Message message) {
|
String _resolveChannelName(ChannelPreview? channel, SCNMessage message) {
|
||||||
return channel?.displayName ?? message.channelInternalName;
|
return channel?.displayName ?? message.channelInternalName;
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Widget> _buildMessageHeader(BuildContext context, Message message, ChannelPreview? channel) {
|
List<Widget> _buildMessageHeader(BuildContext context, SCNMessage message, ChannelPreview? channel) {
|
||||||
return [
|
return [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -167,7 +228,7 @@ class _MessageViewPageState extends State<MessageViewPage> {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Widget> _buildMessageContent(BuildContext context, Message message) {
|
List<Widget> _buildMessageContent(BuildContext context, SCNMessage message) {
|
||||||
return [
|
return [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -178,6 +239,7 @@ class _MessageViewPageState extends State<MessageViewPage> {
|
|||||||
onPressed: () {
|
onPressed: () {
|
||||||
Clipboard.setData(new ClipboardData(text: message.content ?? ''));
|
Clipboard.setData(new ClipboardData(text: message.content ?? ''));
|
||||||
Toaster.info("Clipboard", 'Copied text to Clipboard');
|
Toaster.info("Clipboard", 'Copied text to Clipboard');
|
||||||
|
print('================= [CLIPBOARD] =================\n${message.content}\n================= [/CLIPBOARD] =================');
|
||||||
},
|
},
|
||||||
icon: FontAwesomeIcons.copy,
|
icon: FontAwesomeIcons.copy,
|
||||||
),
|
),
|
||||||
@@ -213,43 +275,20 @@ class _MessageViewPageState extends State<MessageViewPage> {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildMetaCard(BuildContext context, IconData icn, String title, List<String> values, void Function()? action) {
|
String _preformatTitle(SCNMessage message) {
|
||||||
final container = UI.box(
|
|
||||||
context: context,
|
|
||||||
padding: EdgeInsets.fromLTRB(16, 2, 4, 2),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
FaIcon(icn, size: 18),
|
|
||||||
SizedBox(width: 16),
|
|
||||||
Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
|
||||||
for (final val in values) Text(val, style: const TextStyle(fontSize: 14)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (action == null) {
|
|
||||||
return Padding(
|
|
||||||
padding: EdgeInsets.symmetric(vertical: 4, horizontal: 0),
|
|
||||||
child: container,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return Padding(
|
|
||||||
padding: EdgeInsets.symmetric(vertical: 4, horizontal: 0),
|
|
||||||
child: InkWell(
|
|
||||||
splashColor: Theme.of(context).splashColor,
|
|
||||||
onTap: action,
|
|
||||||
child: container,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String _preformatTitle(Message message) {
|
|
||||||
return message.title.replaceAll('\n', '').replaceAll('\r', '').replaceAll('\t', ' ');
|
return message.title.replaceAll('\n', '').replaceAll('\r', '').replaceAll('\t', ' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _prettyPrintPriority(int priority) {
|
||||||
|
switch (priority) {
|
||||||
|
case 0:
|
||||||
|
return 'Low (0)';
|
||||||
|
case 1:
|
||||||
|
return 'Normal (1)';
|
||||||
|
case 2:
|
||||||
|
return 'High (2)';
|
||||||
|
default:
|
||||||
|
return 'Unknown ($priority)';
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
35
flutter/lib/settings/app_settings.dart
Normal file
35
flutter/lib/settings/app_settings.dart
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class AppSettings extends ChangeNotifier {
|
||||||
|
bool groupNotifications = true;
|
||||||
|
int messagePageSize = 128;
|
||||||
|
bool showDebugButton = true;
|
||||||
|
bool backgroundRefreshMessageListOnPop = false;
|
||||||
|
bool alwaysBackgroundRefreshMessageListOnLifecycleResume = true;
|
||||||
|
|
||||||
|
static AppSettings? _singleton = AppSettings._internal();
|
||||||
|
|
||||||
|
factory AppSettings() {
|
||||||
|
return _singleton ?? (_singleton = AppSettings._internal());
|
||||||
|
}
|
||||||
|
|
||||||
|
AppSettings._internal() {
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
void clear() {
|
||||||
|
//TODO
|
||||||
|
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void load() {
|
||||||
|
//TODO
|
||||||
|
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> save() async {
|
||||||
|
//TODO
|
||||||
|
}
|
||||||
|
}
|
@@ -1,8 +1,11 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:simplecloudnotifier/api/api_client.dart';
|
import 'package:simplecloudnotifier/api/api_client.dart';
|
||||||
import 'package:simplecloudnotifier/api/api_exception.dart';
|
import 'package:simplecloudnotifier/api/api_exception.dart';
|
||||||
import 'package:simplecloudnotifier/models/client.dart';
|
import 'package:simplecloudnotifier/models/client.dart';
|
||||||
import 'package:simplecloudnotifier/models/user.dart';
|
import 'package:simplecloudnotifier/models/user.dart';
|
||||||
|
import 'package:simplecloudnotifier/state/application_log.dart';
|
||||||
import 'package:simplecloudnotifier/state/globals.dart';
|
import 'package:simplecloudnotifier/state/globals.dart';
|
||||||
import 'package:simplecloudnotifier/state/token_source.dart';
|
import 'package:simplecloudnotifier/state/token_source.dart';
|
||||||
|
|
||||||
@@ -12,9 +15,9 @@ class AppAuth extends ChangeNotifier implements TokenSource {
|
|||||||
String? _tokenAdmin;
|
String? _tokenAdmin;
|
||||||
String? _tokenSend;
|
String? _tokenSend;
|
||||||
|
|
||||||
User? _user;
|
(User, DateTime)? _user;
|
||||||
Client? _client;
|
|
||||||
DateTime? _clientQueryTime;
|
(Client, DateTime)? _client;
|
||||||
|
|
||||||
String? get userID => _userID;
|
String? get userID => _userID;
|
||||||
String? get tokenAdmin => _tokenAdmin;
|
String? get tokenAdmin => _tokenAdmin;
|
||||||
@@ -35,17 +38,21 @@ class AppAuth extends ChangeNotifier implements TokenSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void set(User user, Client client, String tokenAdmin, String tokenSend) {
|
void set(User user, Client client, String tokenAdmin, String tokenSend) {
|
||||||
_client = client;
|
_client = (client, DateTime.now());
|
||||||
_user = user;
|
|
||||||
|
_user = (user, DateTime.now());
|
||||||
|
|
||||||
_userID = user.userID;
|
_userID = user.userID;
|
||||||
_clientID = client.clientID;
|
_clientID = client.clientID;
|
||||||
|
|
||||||
_tokenAdmin = tokenAdmin;
|
_tokenAdmin = tokenAdmin;
|
||||||
_tokenSend = tokenSend;
|
_tokenSend = tokenSend;
|
||||||
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
void setClientAndClientID(Client client) {
|
void setClientAndClientID(Client client) {
|
||||||
_client = client;
|
_client = (client, DateTime.now());
|
||||||
_clientID = client.clientID;
|
_clientID = client.clientID;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
@@ -83,6 +90,33 @@ class AppAuth extends ChangeNotifier implements TokenSource {
|
|||||||
_client = null;
|
_client = null;
|
||||||
_user = null;
|
_user = null;
|
||||||
|
|
||||||
|
final userjson = Globals().sharedPrefs.getString('auth.user.obj');
|
||||||
|
final userqdate = Globals().sharedPrefs.getString('auth.user.qdate');
|
||||||
|
final clientjson = Globals().sharedPrefs.getString('auth.client.obj');
|
||||||
|
final clientqdate = Globals().sharedPrefs.getString('auth.client.qdate');
|
||||||
|
|
||||||
|
if (userjson != null && userqdate != null) {
|
||||||
|
try {
|
||||||
|
final ts = DateTime.parse(userqdate);
|
||||||
|
final obj = User.fromJson(jsonDecode(userjson) as Map<String, dynamic>);
|
||||||
|
_user = (obj, ts);
|
||||||
|
} catch (exc, trace) {
|
||||||
|
ApplicationLog.error('failed to parse user object from shared-prefs (auth.user.obj): ' + exc.toString(), additional: 'Data:\n${userjson}\nQDate:\n${userqdate}', trace: trace);
|
||||||
|
_user = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clientjson != null && clientqdate != null) {
|
||||||
|
try {
|
||||||
|
final ts = DateTime.parse(clientqdate);
|
||||||
|
final obj = Client.fromJson(jsonDecode(clientjson) as Map<String, dynamic>);
|
||||||
|
_client = (obj, ts);
|
||||||
|
} catch (exc, trace) {
|
||||||
|
ApplicationLog.error('failed to parse user object from shared-prefs (auth.client.obj): ' + exc.toString(), additional: 'Data:\n${clientjson}\nQDate:\n${clientqdate}', trace: trace);
|
||||||
|
_client = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,6 +128,10 @@ class AppAuth extends ChangeNotifier implements TokenSource {
|
|||||||
await Globals().sharedPrefs.remove('auth.tokensend');
|
await Globals().sharedPrefs.remove('auth.tokensend');
|
||||||
await Globals().sharedPrefs.setString('auth.cdate', "");
|
await Globals().sharedPrefs.setString('auth.cdate', "");
|
||||||
await Globals().sharedPrefs.setString('auth.mdate', DateTime.now().toIso8601String());
|
await Globals().sharedPrefs.setString('auth.mdate', DateTime.now().toIso8601String());
|
||||||
|
await Globals().sharedPrefs.remove('auth.user.obj');
|
||||||
|
await Globals().sharedPrefs.remove('auth.user.qdate');
|
||||||
|
await Globals().sharedPrefs.remove('auth.client.obj');
|
||||||
|
await Globals().sharedPrefs.remove('auth.client.qdate');
|
||||||
} else {
|
} else {
|
||||||
await Globals().sharedPrefs.setString('auth.userid', _userID!);
|
await Globals().sharedPrefs.setString('auth.userid', _userID!);
|
||||||
await Globals().sharedPrefs.setString('auth.clientid', _clientID!);
|
await Globals().sharedPrefs.setString('auth.clientid', _clientID!);
|
||||||
@@ -101,14 +139,34 @@ class AppAuth extends ChangeNotifier implements TokenSource {
|
|||||||
await Globals().sharedPrefs.setString('auth.tokensend', _tokenSend!);
|
await Globals().sharedPrefs.setString('auth.tokensend', _tokenSend!);
|
||||||
if (Globals().sharedPrefs.getString('auth.cdate') == null) await Globals().sharedPrefs.setString('auth.cdate', DateTime.now().toIso8601String());
|
if (Globals().sharedPrefs.getString('auth.cdate') == null) await Globals().sharedPrefs.setString('auth.cdate', DateTime.now().toIso8601String());
|
||||||
await Globals().sharedPrefs.setString('auth.mdate', DateTime.now().toIso8601String());
|
await Globals().sharedPrefs.setString('auth.mdate', DateTime.now().toIso8601String());
|
||||||
|
|
||||||
|
if (_user != null) {
|
||||||
|
await Globals().sharedPrefs.setString('auth.user.obj', jsonEncode(_user!.$1.toJson()));
|
||||||
|
await Globals().sharedPrefs.setString('auth.user.qdate', _user!.$2.toIso8601String());
|
||||||
|
} else {
|
||||||
|
await Globals().sharedPrefs.remove('auth.user.obj');
|
||||||
|
await Globals().sharedPrefs.remove('auth.user.qdate');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_client != null) {
|
||||||
|
await Globals().sharedPrefs.setString('auth.client.obj', jsonEncode(_client!.$1.toJson()));
|
||||||
|
await Globals().sharedPrefs.setString('auth.client.qdate', _client!.$2.toIso8601String());
|
||||||
|
} else {
|
||||||
|
await Globals().sharedPrefs.remove('auth.client.obj');
|
||||||
|
await Globals().sharedPrefs.remove('auth.client.qdate');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Globals().sharedPrefs.setString('auth.mdate', DateTime.now().toIso8601String());
|
Globals().sharedPrefs.setString('auth.mdate', DateTime.now().toIso8601String());
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<User> loadUser({bool force = false}) async {
|
Future<User> loadUser({bool force = false, Duration? forceIfOlder = null}) async {
|
||||||
if (!force && _user != null && _user!.userID == _userID) {
|
if (forceIfOlder != null && _user != null && _user!.$2.difference(DateTime.now()) > forceIfOlder) {
|
||||||
return _user!;
|
force = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!force && _user != null && _user!.$1.userID == _userID) {
|
||||||
|
return _user!.$1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_userID == null || _tokenAdmin == null) {
|
if (_userID == null || _tokenAdmin == null) {
|
||||||
@@ -117,20 +175,24 @@ class AppAuth extends ChangeNotifier implements TokenSource {
|
|||||||
|
|
||||||
final user = await APIClient.getUser(this, _userID!);
|
final user = await APIClient.getUser(this, _userID!);
|
||||||
|
|
||||||
_user = user;
|
_user = (user, DateTime.now());
|
||||||
|
|
||||||
await save();
|
await save();
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
User? getUserOrNull() {
|
||||||
|
return _user?.$1;
|
||||||
|
}
|
||||||
|
|
||||||
Future<Client?> loadClient({bool force = false, Duration? forceIfOlder = null}) async {
|
Future<Client?> loadClient({bool force = false, Duration? forceIfOlder = null}) async {
|
||||||
if (forceIfOlder != null && _clientQueryTime != null && _clientQueryTime!.difference(DateTime.now()) > forceIfOlder) {
|
if (forceIfOlder != null && _client != null && _client!.$2.difference(DateTime.now()) > forceIfOlder) {
|
||||||
force = true;
|
force = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!force && _client != null && _client!.clientID == _clientID) {
|
if (!force && _client != null && _client!.$1.clientID == _clientID) {
|
||||||
return _client!;
|
return _client!.$1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_clientID == null || _tokenAdmin == null) {
|
if (_clientID == null || _tokenAdmin == null) {
|
||||||
@@ -140,7 +202,7 @@ class AppAuth extends ChangeNotifier implements TokenSource {
|
|||||||
try {
|
try {
|
||||||
final client = await APIClient.getClient(this, _clientID!);
|
final client = await APIClient.getClient(this, _clientID!);
|
||||||
|
|
||||||
_client = client;
|
_client = (client, DateTime.now());
|
||||||
|
|
||||||
await save();
|
await save();
|
||||||
|
|
||||||
@@ -154,6 +216,10 @@ class AppAuth extends ChangeNotifier implements TokenSource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Client? getClientOrNull() {
|
||||||
|
return _client?.$1;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String getToken() {
|
String getToken() {
|
||||||
return _tokenAdmin!;
|
return _tokenAdmin!;
|
||||||
|
@@ -12,9 +12,18 @@ class AppBarState extends ChangeNotifier {
|
|||||||
bool _loadingIndeterminate = false;
|
bool _loadingIndeterminate = false;
|
||||||
bool get loadingIndeterminate => _loadingIndeterminate;
|
bool get loadingIndeterminate => _loadingIndeterminate;
|
||||||
|
|
||||||
|
bool _showSearchField = false;
|
||||||
|
bool get showSearchField => _showSearchField;
|
||||||
|
|
||||||
void setLoadingIndeterminate(bool v) {
|
void setLoadingIndeterminate(bool v) {
|
||||||
if (_loadingIndeterminate == v) return;
|
if (_loadingIndeterminate == v) return;
|
||||||
_loadingIndeterminate = v;
|
_loadingIndeterminate = v;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setShowSearchField(bool v) {
|
||||||
|
if (_showSearchField == v) return;
|
||||||
|
_showSearchField = v;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
53
flutter/lib/state/app_events.dart
Normal file
53
flutter/lib/state/app_events.dart
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import 'package:simplecloudnotifier/models/scn_message.dart';
|
||||||
|
import 'package:simplecloudnotifier/pages/message_list/message_filter_chiplet.dart';
|
||||||
|
import 'package:simplecloudnotifier/state/application_log.dart';
|
||||||
|
|
||||||
|
class AppEvents {
|
||||||
|
static AppEvents? _singleton = AppEvents._internal();
|
||||||
|
|
||||||
|
factory AppEvents() {
|
||||||
|
return _singleton ?? (_singleton = AppEvents._internal());
|
||||||
|
}
|
||||||
|
|
||||||
|
AppEvents._internal() {}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
List<void Function(List<MessageFilterChipletType> types, List<MessageFilterChiplet>)> _filterListeners = [];
|
||||||
|
|
||||||
|
void subscribeFilterListener(void Function(List<MessageFilterChipletType> types, List<MessageFilterChiplet>) listener) {
|
||||||
|
_filterListeners.add(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
void unsubscribeFilterListener(void Function(List<MessageFilterChipletType> types, List<MessageFilterChiplet>) listener) {
|
||||||
|
_filterListeners.remove(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
void notifyFilterListeners(List<MessageFilterChipletType> types, List<MessageFilterChiplet> query) {
|
||||||
|
ApplicationLog.debug('[AppEvents] onFilter: [${types.join(" ; ")}], [${query.map((e) => e.label).join('|')}]');
|
||||||
|
|
||||||
|
for (var listener in _filterListeners) {
|
||||||
|
listener(types, query);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
List<void Function(SCNMessage)> _messageReceivedListeners = [];
|
||||||
|
|
||||||
|
void subscribeMessageReceivedListener(void Function(SCNMessage) listener) {
|
||||||
|
_messageReceivedListeners.add(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
void unsubscribeMessageReceivedListener(void Function(SCNMessage) listener) {
|
||||||
|
_messageReceivedListeners.remove(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
void notifyMessageReceivedListeners(SCNMessage msg) {
|
||||||
|
ApplicationLog.debug('[AppEvents] onMessageReceived: ${msg.messageID}');
|
||||||
|
|
||||||
|
for (var listener in _messageReceivedListeners) {
|
||||||
|
listener(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -1,6 +1,11 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
@@ -10,6 +15,7 @@ class ApplicationLog {
|
|||||||
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;
|
||||||
Hive.box<SCNLog>('scn-logs').add(SCNLog(
|
Hive.box<SCNLog>('scn-logs').add(SCNLog(
|
||||||
id: Xid().toString(),
|
id: Xid().toString(),
|
||||||
timestamp: DateTime.now(),
|
timestamp: DateTime.now(),
|
||||||
@@ -23,6 +29,7 @@ class ApplicationLog {
|
|||||||
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;
|
||||||
Hive.box<SCNLog>('scn-logs').add(SCNLog(
|
Hive.box<SCNLog>('scn-logs').add(SCNLog(
|
||||||
id: Xid().toString(),
|
id: Xid().toString(),
|
||||||
timestamp: DateTime.now(),
|
timestamp: DateTime.now(),
|
||||||
@@ -36,6 +43,7 @@ class ApplicationLog {
|
|||||||
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;
|
||||||
Hive.box<SCNLog>('scn-logs').add(SCNLog(
|
Hive.box<SCNLog>('scn-logs').add(SCNLog(
|
||||||
id: Xid().toString(),
|
id: Xid().toString(),
|
||||||
timestamp: DateTime.now(),
|
timestamp: DateTime.now(),
|
||||||
@@ -49,6 +57,7 @@ class ApplicationLog {
|
|||||||
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;
|
||||||
Hive.box<SCNLog>('scn-logs').add(SCNLog(
|
Hive.box<SCNLog>('scn-logs').add(SCNLog(
|
||||||
id: Xid().toString(),
|
id: Xid().toString(),
|
||||||
timestamp: DateTime.now(),
|
timestamp: DateTime.now(),
|
||||||
@@ -62,6 +71,7 @@ class ApplicationLog {
|
|||||||
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}');
|
||||||
|
|
||||||
|
if (!Hive.isBoxOpen('scn-logs')) return;
|
||||||
Hive.box<SCNLog>('scn-logs').add(SCNLog(
|
Hive.box<SCNLog>('scn-logs').add(SCNLog(
|
||||||
id: Xid().toString(),
|
id: Xid().toString(),
|
||||||
timestamp: DateTime.now(),
|
timestamp: DateTime.now(),
|
||||||
@@ -71,6 +81,61 @@ class ApplicationLog {
|
|||||||
trace: trace?.toString() ?? '',
|
trace: trace?.toString() ?? '',
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void writeRawFailure(String message, Map<String, dynamic> extraData) async {
|
||||||
|
try {
|
||||||
|
await Globals().init();
|
||||||
|
|
||||||
|
final fn = path.join(Globals().rawFailureLogsDir.path, 'failure-${DateTime.now().toIso8601String()}.log');
|
||||||
|
|
||||||
|
var txt = "[TEXT]\n${message}\n\n";
|
||||||
|
for (var k in extraData.keys) {
|
||||||
|
txt += "[${k}]\n${_debugToStr(extraData[k])}\n\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
await File(fn).writeAsString(txt);
|
||||||
|
|
||||||
|
ApplicationLog.debug("Wrote raw failure log to '${fn}' ('${message}')");
|
||||||
|
} catch (e) {
|
||||||
|
print("Failed to <writeRawFailure>: ${e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _debugToStr(dynamic v) {
|
||||||
|
if (v is String) {
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
if (v is StackTrace) {
|
||||||
|
return v.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final enc = new JsonEncoder.withIndent(" ");
|
||||||
|
return enc.convert(v);
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return jsonEncode(v);
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return v.toString();
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return "${v}";
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
return "<[!]FAILED_TO_PRINT_OBJECT>";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@HiveType(typeId: 103)
|
@HiveType(typeId: 103)
|
||||||
|
@@ -32,8 +32,6 @@ class FBMessage extends HiveObject implements FieldDebuggable {
|
|||||||
final String? messageType;
|
final String? messageType;
|
||||||
@HiveField(8)
|
@HiveField(8)
|
||||||
final bool mutableContent;
|
final bool mutableContent;
|
||||||
@HiveField(9)
|
|
||||||
final RemoteNotification? notification;
|
|
||||||
@HiveField(10)
|
@HiveField(10)
|
||||||
final DateTime? sentTime;
|
final DateTime? sentTime;
|
||||||
@HiveField(11)
|
@HiveField(11)
|
||||||
@@ -54,7 +52,7 @@ class FBMessage extends HiveObject implements FieldDebuggable {
|
|||||||
@HiveField(25)
|
@HiveField(25)
|
||||||
final String? notificationAndroidLink;
|
final String? notificationAndroidLink;
|
||||||
@HiveField(26)
|
@HiveField(26)
|
||||||
final AndroidNotificationPriority? notificationAndroidPriority;
|
final String? notificationAndroidPriority;
|
||||||
@HiveField(27)
|
@HiveField(27)
|
||||||
final String? notificationAndroidSmallIcon;
|
final String? notificationAndroidSmallIcon;
|
||||||
@HiveField(28)
|
@HiveField(28)
|
||||||
@@ -62,14 +60,14 @@ class FBMessage extends HiveObject implements FieldDebuggable {
|
|||||||
@HiveField(29)
|
@HiveField(29)
|
||||||
final String? notificationAndroidTicker;
|
final String? notificationAndroidTicker;
|
||||||
@HiveField(30)
|
@HiveField(30)
|
||||||
final AndroidNotificationVisibility? notificationAndroidVisibility;
|
final String? notificationAndroidVisibility;
|
||||||
@HiveField(31)
|
@HiveField(31)
|
||||||
final String? notificationAndroidTag;
|
final String? notificationAndroidTag;
|
||||||
|
|
||||||
@HiveField(40)
|
@HiveField(40)
|
||||||
final String? notificationAppleBadge;
|
final String? notificationAppleBadge;
|
||||||
@HiveField(41)
|
@HiveField(41)
|
||||||
final AppleNotificationSound? notificationAppleSound;
|
final String? notificationAppleSound;
|
||||||
@HiveField(42)
|
@HiveField(42)
|
||||||
final String? notificationAppleImageUrl;
|
final String? notificationAppleImageUrl;
|
||||||
@HiveField(43)
|
@HiveField(43)
|
||||||
@@ -109,7 +107,6 @@ class FBMessage extends HiveObject implements FieldDebuggable {
|
|||||||
required this.messageId,
|
required this.messageId,
|
||||||
required this.messageType,
|
required this.messageType,
|
||||||
required this.mutableContent,
|
required this.mutableContent,
|
||||||
required this.notification,
|
|
||||||
required this.sentTime,
|
required this.sentTime,
|
||||||
required this.threadId,
|
required this.threadId,
|
||||||
required this.ttl,
|
required this.ttl,
|
||||||
@@ -152,7 +149,6 @@ class FBMessage extends HiveObject implements FieldDebuggable {
|
|||||||
this.messageId = rmsg.messageId,
|
this.messageId = rmsg.messageId,
|
||||||
this.messageType = rmsg.messageType,
|
this.messageType = rmsg.messageType,
|
||||||
this.mutableContent = rmsg.mutableContent,
|
this.mutableContent = rmsg.mutableContent,
|
||||||
this.notification = rmsg.notification,
|
|
||||||
this.sentTime = rmsg.sentTime,
|
this.sentTime = rmsg.sentTime,
|
||||||
this.threadId = rmsg.threadId,
|
this.threadId = rmsg.threadId,
|
||||||
this.ttl = rmsg.ttl,
|
this.ttl = rmsg.ttl,
|
||||||
@@ -162,14 +158,14 @@ class FBMessage extends HiveObject implements FieldDebuggable {
|
|||||||
this.notificationAndroidCount = rmsg.notification?.android?.count,
|
this.notificationAndroidCount = rmsg.notification?.android?.count,
|
||||||
this.notificationAndroidImageUrl = rmsg.notification?.android?.imageUrl,
|
this.notificationAndroidImageUrl = rmsg.notification?.android?.imageUrl,
|
||||||
this.notificationAndroidLink = rmsg.notification?.android?.link,
|
this.notificationAndroidLink = rmsg.notification?.android?.link,
|
||||||
this.notificationAndroidPriority = rmsg.notification?.android?.priority,
|
this.notificationAndroidPriority = rmsg.notification?.android?.priority.toString(),
|
||||||
this.notificationAndroidSmallIcon = rmsg.notification?.android?.smallIcon,
|
this.notificationAndroidSmallIcon = rmsg.notification?.android?.smallIcon,
|
||||||
this.notificationAndroidSound = rmsg.notification?.android?.sound,
|
this.notificationAndroidSound = rmsg.notification?.android?.sound,
|
||||||
this.notificationAndroidTicker = rmsg.notification?.android?.ticker,
|
this.notificationAndroidTicker = rmsg.notification?.android?.ticker,
|
||||||
this.notificationAndroidVisibility = rmsg.notification?.android?.visibility,
|
this.notificationAndroidVisibility = rmsg.notification?.android?.visibility.toString(),
|
||||||
this.notificationAndroidTag = rmsg.notification?.android?.tag,
|
this.notificationAndroidTag = rmsg.notification?.android?.tag,
|
||||||
this.notificationAppleBadge = rmsg.notification?.apple?.badge,
|
this.notificationAppleBadge = rmsg.notification?.apple?.badge,
|
||||||
this.notificationAppleSound = rmsg.notification?.apple?.sound,
|
this.notificationAppleSound = rmsg.notification?.apple?.sound?.toString(),
|
||||||
this.notificationAppleImageUrl = rmsg.notification?.apple?.imageUrl,
|
this.notificationAppleImageUrl = rmsg.notification?.apple?.imageUrl,
|
||||||
this.notificationAppleSubtitle = rmsg.notification?.apple?.subtitle,
|
this.notificationAppleSubtitle = rmsg.notification?.apple?.subtitle,
|
||||||
this.notificationAppleSubtitleLocArgs = rmsg.notification?.apple?.subtitleLocArgs,
|
this.notificationAppleSubtitleLocArgs = rmsg.notification?.apple?.subtitleLocArgs,
|
||||||
@@ -195,12 +191,11 @@ class FBMessage extends HiveObject implements FieldDebuggable {
|
|||||||
('category', this.category ?? ''),
|
('category', this.category ?? ''),
|
||||||
('collapseKey', this.collapseKey ?? ''),
|
('collapseKey', this.collapseKey ?? ''),
|
||||||
('contentAvailable', this.contentAvailable.toString()),
|
('contentAvailable', this.contentAvailable.toString()),
|
||||||
('data', this.data.toString()),
|
('data', this.data.entries.map((e) => '${e.key} := ${e.value}').join('\n')),
|
||||||
('from', this.from ?? ''),
|
('from', this.from ?? ''),
|
||||||
('messageId', this.messageId ?? ''),
|
('messageId', this.messageId ?? ''),
|
||||||
('messageType', this.messageType ?? ''),
|
('messageType', this.messageType ?? ''),
|
||||||
('mutableContent', this.mutableContent.toString()),
|
('mutableContent', this.mutableContent.toString()),
|
||||||
('notification', this.notification?.toString() ?? ''),
|
|
||||||
('sentTime', this.sentTime?.toString() ?? ''),
|
('sentTime', this.sentTime?.toString() ?? ''),
|
||||||
('threadId', this.threadId ?? ''),
|
('threadId', this.threadId ?? ''),
|
||||||
('ttl', this.ttl?.toString() ?? ''),
|
('ttl', this.ttl?.toString() ?? ''),
|
||||||
|
@@ -26,7 +26,6 @@ class FBMessageAdapter extends TypeAdapter<FBMessage> {
|
|||||||
messageId: fields[6] as String?,
|
messageId: fields[6] as String?,
|
||||||
messageType: fields[7] as String?,
|
messageType: fields[7] as String?,
|
||||||
mutableContent: fields[8] as bool,
|
mutableContent: fields[8] as bool,
|
||||||
notification: fields[9] as RemoteNotification?,
|
|
||||||
sentTime: fields[10] as DateTime?,
|
sentTime: fields[10] as DateTime?,
|
||||||
threadId: fields[11] as String?,
|
threadId: fields[11] as String?,
|
||||||
ttl: fields[12] as int?,
|
ttl: fields[12] as int?,
|
||||||
@@ -36,15 +35,14 @@ class FBMessageAdapter extends TypeAdapter<FBMessage> {
|
|||||||
notificationAndroidCount: fields[23] as int?,
|
notificationAndroidCount: fields[23] as int?,
|
||||||
notificationAndroidImageUrl: fields[24] as String?,
|
notificationAndroidImageUrl: fields[24] as String?,
|
||||||
notificationAndroidLink: fields[25] as String?,
|
notificationAndroidLink: fields[25] as String?,
|
||||||
notificationAndroidPriority: fields[26] as AndroidNotificationPriority?,
|
notificationAndroidPriority: fields[26] as String?,
|
||||||
notificationAndroidSmallIcon: fields[27] as String?,
|
notificationAndroidSmallIcon: fields[27] as String?,
|
||||||
notificationAndroidSound: fields[28] as String?,
|
notificationAndroidSound: fields[28] as String?,
|
||||||
notificationAndroidTicker: fields[29] as String?,
|
notificationAndroidTicker: fields[29] as String?,
|
||||||
notificationAndroidVisibility:
|
notificationAndroidVisibility: fields[30] as String?,
|
||||||
fields[30] as AndroidNotificationVisibility?,
|
|
||||||
notificationAndroidTag: fields[31] as String?,
|
notificationAndroidTag: fields[31] as String?,
|
||||||
notificationAppleBadge: fields[40] as String?,
|
notificationAppleBadge: fields[40] as String?,
|
||||||
notificationAppleSound: fields[41] as AppleNotificationSound?,
|
notificationAppleSound: fields[41] as String?,
|
||||||
notificationAppleImageUrl: fields[42] as String?,
|
notificationAppleImageUrl: fields[42] as String?,
|
||||||
notificationAppleSubtitle: fields[43] as String?,
|
notificationAppleSubtitle: fields[43] as String?,
|
||||||
notificationAppleSubtitleLocArgs: (fields[44] as List?)?.cast<String>(),
|
notificationAppleSubtitleLocArgs: (fields[44] as List?)?.cast<String>(),
|
||||||
@@ -64,7 +62,7 @@ class FBMessageAdapter extends TypeAdapter<FBMessage> {
|
|||||||
@override
|
@override
|
||||||
void write(BinaryWriter writer, FBMessage obj) {
|
void write(BinaryWriter writer, FBMessage obj) {
|
||||||
writer
|
writer
|
||||||
..writeByte(40)
|
..writeByte(39)
|
||||||
..writeByte(0)
|
..writeByte(0)
|
||||||
..write(obj.senderId)
|
..write(obj.senderId)
|
||||||
..writeByte(1)
|
..writeByte(1)
|
||||||
@@ -83,8 +81,6 @@ class FBMessageAdapter extends TypeAdapter<FBMessage> {
|
|||||||
..write(obj.messageType)
|
..write(obj.messageType)
|
||||||
..writeByte(8)
|
..writeByte(8)
|
||||||
..write(obj.mutableContent)
|
..write(obj.mutableContent)
|
||||||
..writeByte(9)
|
|
||||||
..write(obj.notification)
|
|
||||||
..writeByte(10)
|
..writeByte(10)
|
||||||
..write(obj.sentTime)
|
..write(obj.sentTime)
|
||||||
..writeByte(11)
|
..writeByte(11)
|
||||||
|
@@ -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();
|
||||||
@@ -13,6 +15,8 @@ class Globals {
|
|||||||
|
|
||||||
Globals._internal();
|
Globals._internal();
|
||||||
|
|
||||||
|
bool _initialized = false;
|
||||||
|
|
||||||
String appName = '';
|
String appName = '';
|
||||||
String packageName = '';
|
String packageName = '';
|
||||||
String version = '';
|
String version = '';
|
||||||
@@ -24,7 +28,14 @@ class Globals {
|
|||||||
|
|
||||||
late SharedPreferences sharedPrefs;
|
late SharedPreferences sharedPrefs;
|
||||||
|
|
||||||
|
late Directory appDocumentsDir;
|
||||||
|
late Directory rawFailureLogsDir;
|
||||||
|
|
||||||
|
bool get isInitialized => _initialized;
|
||||||
|
|
||||||
Future<void> init() async {
|
Future<void> init() async {
|
||||||
|
if (_initialized) return;
|
||||||
|
|
||||||
PackageInfo packageInfo = await PackageInfo.fromPlatform();
|
PackageInfo packageInfo = await PackageInfo.fromPlatform();
|
||||||
|
|
||||||
this.appName = packageInfo.appName;
|
this.appName = packageInfo.appName;
|
||||||
@@ -54,6 +65,13 @@ class Globals {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
String? getPrefFCMToken() {
|
String? getPrefFCMToken() {
|
||||||
|
60
flutter/lib/state/scn_data_cache.dart
Normal file
60
flutter/lib/state/scn_data_cache.dart
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import 'package:hive_flutter/hive_flutter.dart';
|
||||||
|
import 'package:simplecloudnotifier/models/channel.dart';
|
||||||
|
import 'package:simplecloudnotifier/models/scn_message.dart';
|
||||||
|
import 'package:simplecloudnotifier/settings/app_settings.dart';
|
||||||
|
|
||||||
|
class SCNDataCache {
|
||||||
|
SCNDataCache._internal();
|
||||||
|
static final SCNDataCache _instance = SCNDataCache._internal();
|
||||||
|
factory SCNDataCache() => _instance;
|
||||||
|
|
||||||
|
Future<void> addToMessageCache(List<SCNMessage> newItems) async {
|
||||||
|
final cfg = AppSettings();
|
||||||
|
|
||||||
|
final cache = Hive.box<SCNMessage>('scn-message-cache');
|
||||||
|
|
||||||
|
for (var msg in newItems) await cache.put(msg.messageID, msg);
|
||||||
|
|
||||||
|
// delete all but the newest 128 messages
|
||||||
|
|
||||||
|
if (cache.length < cfg.messagePageSize) return;
|
||||||
|
|
||||||
|
final allValues = cache.values.toList();
|
||||||
|
|
||||||
|
allValues.sort((a, b) => -1 * a.timestamp.compareTo(b.timestamp));
|
||||||
|
|
||||||
|
for (var val in allValues.sublist(cfg.messagePageSize)) {
|
||||||
|
await cache.delete(val.messageID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setChannelCache(List<ChannelWithSubscription> channels) async {
|
||||||
|
final cache = Hive.box<Channel>('scn-channel-cache');
|
||||||
|
|
||||||
|
if (cache.length != channels.length) await cache.clear();
|
||||||
|
|
||||||
|
for (var chn in channels) await cache.put(chn.channel.channelID, chn.channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool hasMessagesAndChannels() {
|
||||||
|
final chnCache = Hive.box<Channel>('scn-channel-cache');
|
||||||
|
final msgCache = Hive.box<SCNMessage>('scn-message-cache');
|
||||||
|
|
||||||
|
return chnCache.isNotEmpty && msgCache.isNotEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Channel> getChannelMap() {
|
||||||
|
final chnCache = Hive.box<Channel>('scn-channel-cache');
|
||||||
|
|
||||||
|
return <String, Channel>{for (var v in chnCache.values) v.channelID: v};
|
||||||
|
}
|
||||||
|
|
||||||
|
List<SCNMessage> getMessagesSorted() {
|
||||||
|
final msgCache = Hive.box<SCNMessage>('scn-message-cache');
|
||||||
|
|
||||||
|
final cacheMessages = msgCache.values.toList();
|
||||||
|
cacheMessages.sort((a, b) => -1 * a.timestamp.compareTo(b.timestamp));
|
||||||
|
|
||||||
|
return cacheMessages;
|
||||||
|
}
|
||||||
|
}
|
@@ -1,18 +1,26 @@
|
|||||||
// This class is useful togther with FutureBuilder
|
// This class is useful togther with FutureBuilder
|
||||||
// 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
|
||||||
// Whit way we can set the ImmediateFuture.value directly and circumvent that.
|
// This way we can set the ImmediateFuture.value directly and circumvent that.
|
||||||
|
|
||||||
class ImmediateFuture<T> {
|
class ImmediateFuture<T> {
|
||||||
final Future<T> future;
|
final Future<T> future;
|
||||||
final T? value;
|
final T? value;
|
||||||
|
|
||||||
|
T? _futureValue = null;
|
||||||
|
|
||||||
ImmediateFuture(this.future, this.value);
|
ImmediateFuture(this.future, this.value);
|
||||||
|
|
||||||
ImmediateFuture.ofFuture(Future<T> v)
|
ImmediateFuture.ofFuture(Future<T> v)
|
||||||
: future = v,
|
: future = v,
|
||||||
value = null;
|
value = null {
|
||||||
|
future.then((v) => _futureValue = v);
|
||||||
|
}
|
||||||
|
|
||||||
ImmediateFuture.ofValue(T v)
|
ImmediateFuture.ofValue(T v)
|
||||||
: future = Future.value(v),
|
: future = Future.value(v),
|
||||||
value = v;
|
value = v;
|
||||||
|
|
||||||
|
T? get() {
|
||||||
|
return value ?? _futureValue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -8,15 +8,21 @@ class Navi {
|
|||||||
|
|
||||||
static void push<T extends Widget>(BuildContext context, T Function() builder) {
|
static void push<T extends Widget>(BuildContext context, T Function() builder) {
|
||||||
Provider.of<AppBarState>(context, listen: false).setLoadingIndeterminate(false);
|
Provider.of<AppBarState>(context, listen: false).setLoadingIndeterminate(false);
|
||||||
|
Provider.of<AppBarState>(context, listen: false).setShowSearchField(false);
|
||||||
|
|
||||||
Navigator.push(context, MaterialPageRoute<T>(builder: (context) => builder()));
|
Navigator.push(context, MaterialPageRoute<T>(builder: (context) => builder()));
|
||||||
}
|
}
|
||||||
|
|
||||||
static void popToRoot(BuildContext context) {
|
static void popToRoot(BuildContext context) {
|
||||||
Provider.of<AppBarState>(context, listen: false).setLoadingIndeterminate(false);
|
Provider.of<AppBarState>(context, listen: false).setLoadingIndeterminate(false);
|
||||||
|
Provider.of<AppBarState>(context, listen: false).setShowSearchField(false);
|
||||||
|
|
||||||
Navigator.popUntil(context, (route) => route.isFirst);
|
Navigator.popUntil(context, (route) => route.isFirst);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void popDialog(BuildContext dialogContext) {
|
||||||
|
Navigator.pop(dialogContext);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class SCNRouteObserver extends RouteObserver<PageRoute<dynamic>> {
|
class SCNRouteObserver extends RouteObserver<PageRoute<dynamic>> {
|
||||||
@@ -25,6 +31,7 @@ class SCNRouteObserver extends RouteObserver<PageRoute<dynamic>> {
|
|||||||
super.didPush(route, previousRoute);
|
super.didPush(route, previousRoute);
|
||||||
if (route is PageRoute) {
|
if (route is PageRoute) {
|
||||||
AppBarState().setLoadingIndeterminate(false);
|
AppBarState().setLoadingIndeterminate(false);
|
||||||
|
AppBarState().setShowSearchField(false);
|
||||||
|
|
||||||
print('[SCNRouteObserver] .didPush()');
|
print('[SCNRouteObserver] .didPush()');
|
||||||
}
|
}
|
||||||
@@ -35,6 +42,7 @@ class SCNRouteObserver extends RouteObserver<PageRoute<dynamic>> {
|
|||||||
super.didReplace(newRoute: newRoute, oldRoute: oldRoute);
|
super.didReplace(newRoute: newRoute, oldRoute: oldRoute);
|
||||||
if (newRoute is PageRoute) {
|
if (newRoute is PageRoute) {
|
||||||
AppBarState().setLoadingIndeterminate(false);
|
AppBarState().setLoadingIndeterminate(false);
|
||||||
|
AppBarState().setShowSearchField(false);
|
||||||
|
|
||||||
print('[SCNRouteObserver] .didReplace()');
|
print('[SCNRouteObserver] .didReplace()');
|
||||||
}
|
}
|
||||||
@@ -45,6 +53,7 @@ class SCNRouteObserver extends RouteObserver<PageRoute<dynamic>> {
|
|||||||
super.didPop(route, previousRoute);
|
super.didPop(route, previousRoute);
|
||||||
if (previousRoute is PageRoute && route is PageRoute) {
|
if (previousRoute is PageRoute && route is PageRoute) {
|
||||||
AppBarState().setLoadingIndeterminate(false);
|
AppBarState().setLoadingIndeterminate(false);
|
||||||
|
AppBarState().setShowSearchField(false);
|
||||||
|
|
||||||
print('[SCNRouteObserver] .didPop()');
|
print('[SCNRouteObserver] .didPop()');
|
||||||
}
|
}
|
||||||
|
83
flutter/lib/utils/notifier.dart
Normal file
83
flutter/lib/utils/notifier.dart
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
|
import 'package:simplecloudnotifier/settings/app_settings.dart';
|
||||||
|
import 'package:simplecloudnotifier/state/application_log.dart';
|
||||||
|
import 'package:simplecloudnotifier/state/globals.dart';
|
||||||
|
|
||||||
|
class Notifier {
|
||||||
|
static void showLocalNotification(String messageID, String channelID, String channelName, String channelDescr, String title, String body, DateTime? timestamp) async {
|
||||||
|
final nid = Globals().sharedPrefs.getInt('notifier.nextid') ?? 1000;
|
||||||
|
Globals().sharedPrefs.setInt('notifier.nextid', nid + 7);
|
||||||
|
|
||||||
|
final existingSummaryNID = Globals().sharedPrefs.getInt('notifier.summary.$channelID');
|
||||||
|
|
||||||
|
final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();
|
||||||
|
|
||||||
|
if (Platform.isAndroid && AppSettings().groupNotifications) {
|
||||||
|
final activeNotifications = (await flutterLocalNotificationsPlugin.getActiveNotifications()).where((p) => p.groupKey == channelID).toList();
|
||||||
|
final summaryNotification = activeNotifications.where((p) => p.id == existingSummaryNID).toList();
|
||||||
|
|
||||||
|
ApplicationLog.debug('found ${activeNotifications.length} active notifications in this group (${summaryNotification.length} summary notifications for channel ${channelID} with nid [${existingSummaryNID}])');
|
||||||
|
|
||||||
|
if (activeNotifications.isNotEmpty && !activeNotifications.any((p) => p.id == existingSummaryNID)) {
|
||||||
|
// ======== SHOW SUMMARY/GROUPING NOTIFICATION ========
|
||||||
|
final newSummaryNID = nid + 1;
|
||||||
|
ApplicationLog.debug('Create new summary notifications for channel ${channelID} with nid [${newSummaryNID}])');
|
||||||
|
Globals().sharedPrefs.setInt('notifier.summary.$channelID', newSummaryNID);
|
||||||
|
|
||||||
|
var payload = '';
|
||||||
|
if (messageID != '') {
|
||||||
|
payload = ['@SCN_MESSAGE_SUMMARY', channelID, newSummaryNID].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
await flutterLocalNotificationsPlugin.show(
|
||||||
|
newSummaryNID,
|
||||||
|
channelName,
|
||||||
|
"(multiple notifications)",
|
||||||
|
NotificationDetails(
|
||||||
|
android: AndroidNotificationDetails(
|
||||||
|
channelID,
|
||||||
|
channelName,
|
||||||
|
importance: Importance.max,
|
||||||
|
priority: Priority.high,
|
||||||
|
groupKey: channelID,
|
||||||
|
setAsGroupSummary: true,
|
||||||
|
subText: (channelName == 'main') ? null : channelName,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
payload: payload,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final newMessageNID = nid + 2;
|
||||||
|
|
||||||
|
ApplicationLog.debug('Create new local notifications for message in channel ${channelID} with nid [${newMessageNID}])');
|
||||||
|
|
||||||
|
var payload = '';
|
||||||
|
if (messageID != '') {
|
||||||
|
payload = ['@SCN_MESSAGE', messageID, channelID, newMessageNID].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======== SHOW NOTIFICATION ========
|
||||||
|
await flutterLocalNotificationsPlugin.show(
|
||||||
|
newMessageNID,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
NotificationDetails(
|
||||||
|
android: AndroidNotificationDetails(
|
||||||
|
channelID,
|
||||||
|
channelName,
|
||||||
|
channelDescription: channelDescr,
|
||||||
|
importance: Importance.max,
|
||||||
|
priority: Priority.high,
|
||||||
|
when: timestamp?.millisecondsSinceEpoch,
|
||||||
|
groupKey: channelID,
|
||||||
|
subText: (channelName == 'main') ? null : channelName,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
payload: payload,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@@ -72,7 +72,7 @@ class UI {
|
|||||||
splashColor: Theme.of(context).splashColor,
|
splashColor: Theme.of(context).splashColor,
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
child: child,
|
child: child,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -106,4 +106,49 @@ class UI {
|
|||||||
child: child,
|
child: child,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Widget metaCard({required BuildContext context, required IconData icon, required String title, required List<String> values, void Function()? mainAction, List<(IconData, void Function())>? iconActions}) {
|
||||||
|
final container = UI.box(
|
||||||
|
context: context,
|
||||||
|
padding: EdgeInsets.fromLTRB(16, 2, 4, 2),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
FaIcon(icon, size: 18),
|
||||||
|
SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||||
|
for (final val in values) Text(val, style: const TextStyle(fontSize: 14)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (iconActions != null) ...[
|
||||||
|
SizedBox(width: 12),
|
||||||
|
for (final iconAction in iconActions) ...[
|
||||||
|
SizedBox(width: 4),
|
||||||
|
IconButton(icon: FaIcon(iconAction.$1), onPressed: iconAction.$2),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mainAction == null) {
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 4, horizontal: 0),
|
||||||
|
child: container,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 4, horizontal: 0),
|
||||||
|
child: InkWell(
|
||||||
|
splashColor: Theme.of(context).splashColor,
|
||||||
|
onTap: mainAction,
|
||||||
|
child: container,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -8,6 +8,8 @@ import Foundation
|
|||||||
import device_info_plus
|
import device_info_plus
|
||||||
import firebase_core
|
import firebase_core
|
||||||
import firebase_messaging
|
import firebase_messaging
|
||||||
|
import flutter_local_notifications
|
||||||
|
import mobile_scanner
|
||||||
import package_info_plus
|
import package_info_plus
|
||||||
import path_provider_foundation
|
import path_provider_foundation
|
||||||
import share_plus
|
import share_plus
|
||||||
@@ -18,6 +20,8 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
|||||||
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
|
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
|
||||||
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
|
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
|
||||||
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
|
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
|
||||||
|
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||||
|
MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin"))
|
||||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||||
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
|
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
|
||||||
|
@@ -5,10 +5,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: _fe_analyzer_shared
|
name: _fe_analyzer_shared
|
||||||
sha256: "0b2f2bd91ba804e53a61d757b986f89f1f9eaed5b11e4b2f5a2468d86d6c9fc7"
|
sha256: f256b0c0ba6c7577c15e2e4e114755640a875e885099367bf6e012b19314c834
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "67.0.0"
|
version: "72.0.0"
|
||||||
_flutterfire_internals:
|
_flutterfire_internals:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -17,14 +17,19 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.3.35"
|
version: "1.3.35"
|
||||||
|
_macros:
|
||||||
|
dependency: transitive
|
||||||
|
description: dart
|
||||||
|
source: sdk
|
||||||
|
version: "0.3.2"
|
||||||
analyzer:
|
analyzer:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: analyzer
|
name: analyzer
|
||||||
sha256: "37577842a27e4338429a1cbc32679d508836510b056f1eedf0c8d20e39c1383d"
|
sha256: b652861553cd3990d8ed361f7979dc6d7053a9ac8843fa73820ab68ce5410139
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.4.1"
|
version: "6.7.0"
|
||||||
archive:
|
archive:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -37,10 +42,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: args
|
name: args
|
||||||
sha256: "7cf60b9f0cc88203c5a190b4cd62a99feea42759a7fa695010eb5de1c0b2252a"
|
sha256: bf9f5caeea8d8fe6721a9c358dd8a5c1947b27f1cfaa18b39c301273594919e6
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.5.0"
|
version: "2.6.0"
|
||||||
async:
|
async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -77,10 +82,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: build_daemon
|
name: build_daemon
|
||||||
sha256: "0343061a33da9c5810b2d6cee51945127d8f4c060b7fbdd9d54917f0a3feaaa1"
|
sha256: "79b2aef6ac2ed00046867ed354c88778c9c0f029df8a20fe10b5436826721ef9"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.0.1"
|
version: "4.0.2"
|
||||||
build_resolvers:
|
build_resolvers:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -93,18 +98,18 @@ packages:
|
|||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: build_runner
|
name: build_runner
|
||||||
sha256: "3ac61a79bfb6f6cc11f693591063a7f19a7af628dc52f141743edac5c16e8c22"
|
sha256: "028819cfb90051c6b5440c7e574d1896f8037e3c96cf17aaeb054c9311cfbf4d"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.9"
|
version: "2.4.13"
|
||||||
build_runner_core:
|
build_runner_core:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: build_runner_core
|
name: build_runner_core
|
||||||
sha256: "4ae8ffe5ac758da294ecf1802f2aff01558d8b1b00616aa7538ea9a8a5d50799"
|
sha256: f8126682b87a7282a339b871298cc12009cb67109cfa1614d6436fb0289193e0
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.3.0"
|
version: "7.3.2"
|
||||||
built_collection:
|
built_collection:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -173,26 +178,26 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: convert
|
name: convert
|
||||||
sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592"
|
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.1"
|
version: "3.1.2"
|
||||||
cross_file:
|
cross_file:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: cross_file
|
name: cross_file
|
||||||
sha256: "55d7b444feb71301ef6b8838dbc1ae02e63dd48c8773f3810ff53bb1e2945b32"
|
sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.3.4+1"
|
version: "0.3.4+2"
|
||||||
crypto:
|
crypto:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: crypto
|
name: crypto
|
||||||
sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab
|
sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.3"
|
version: "3.0.6"
|
||||||
cupertino_icons:
|
cupertino_icons:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -205,26 +210,34 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: dart_style
|
name: dart_style
|
||||||
sha256: "99e066ce75c89d6b29903d788a7bb9369cf754f7b24bf70bf4b6d6d6b26853b9"
|
sha256: "7856d364b589d1f08986e140938578ed36ed948581fbc3bc9aef1805039ac5ab"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.6"
|
version: "2.3.7"
|
||||||
|
dbus:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: dbus
|
||||||
|
sha256: "365c771ac3b0e58845f39ec6deebc76e3276aa9922b0cc60840712094d9047ac"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.7.10"
|
||||||
device_info_plus:
|
device_info_plus:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: device_info_plus
|
name: device_info_plus
|
||||||
sha256: eead12d1a1ed83d8283ab4c2f3fca23ac4082f29f25f29dff0f758f57d06ec91
|
sha256: a7fd703482b391a87d60b6061d04dfdeab07826b96f9abd8f5ed98068acc0074
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "10.1.0"
|
version: "10.1.2"
|
||||||
device_info_plus_platform_interface:
|
device_info_plus_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: device_info_plus_platform_interface
|
name: device_info_plus_platform_interface
|
||||||
sha256: d3b01d5868b50ae571cd1dc6e502fc94d956b665756180f7b16ead09e836fd64
|
sha256: "282d3cf731045a2feb66abfe61bbc40870ae50a3ed10a4d3d217556c35c8c2ba"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.0"
|
version: "7.0.1"
|
||||||
equatable:
|
equatable:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -245,18 +258,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: ffi
|
name: ffi
|
||||||
sha256: "493f37e7df1804778ff3a53bd691d8692ddf69702cf4c1c1096a2e41b4779e21"
|
sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.2"
|
version: "2.1.3"
|
||||||
file:
|
file:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: file
|
name: file
|
||||||
sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c"
|
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.0"
|
version: "7.0.1"
|
||||||
firebase_core:
|
firebase_core:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -269,18 +282,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: firebase_core_platform_interface
|
name: firebase_core_platform_interface
|
||||||
sha256: "1003a5a03a61fc9a22ef49f37cbcb9e46c86313a7b2e7029b9390cf8c6fc32cb"
|
sha256: e30da58198a6d4b49d5bce4e852f985c32cb10db329ebef9473db2b9f09ce810
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.1.0"
|
version: "5.3.0"
|
||||||
firebase_core_web:
|
firebase_core_web:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: firebase_core_web
|
name: firebase_core_web
|
||||||
sha256: "6643fe3dbd021e6ccfb751f7882b39df355708afbdeb4130fc50f9305a9d1a3d"
|
sha256: "362e52457ed2b7b180964769c1e04d1e0ea0259fdf7025fdfedd019d4ae2bd88"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.17.2"
|
version: "2.17.5"
|
||||||
firebase_messaging:
|
firebase_messaging:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -309,10 +322,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: fixnum
|
name: fixnum
|
||||||
sha256: "25517a4deb0c03aa0f32fd12db525856438902d9c16536311e76cdc57b31d7d1"
|
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.0"
|
version: "1.1.1"
|
||||||
flutter:
|
flutter:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -342,6 +355,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.0.0"
|
version: "4.0.0"
|
||||||
|
flutter_local_notifications:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: flutter_local_notifications
|
||||||
|
sha256: "49eeef364fddb71515bc78d5a8c51435a68bccd6e4d68e25a942c5e47761ae71"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "17.2.3"
|
||||||
|
flutter_local_notifications_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_local_notifications_linux
|
||||||
|
sha256: c49bd06165cad9beeb79090b18cd1eb0296f4bf4b23b84426e37dd7c027fc3af
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.0.1"
|
||||||
|
flutter_local_notifications_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_local_notifications_platform_interface
|
||||||
|
sha256: "85f8d07fe708c1bdcf45037f2c0109753b26ae077e9d9e899d55971711a4ea66"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "7.2.0"
|
||||||
flutter_staggered_grid_view:
|
flutter_staggered_grid_view:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -387,10 +424,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: graphs
|
name: graphs
|
||||||
sha256: aedc5a15e78fc65a6e23bcd927f24c64dd995062bcd1ca6eda65a3cff92a4d19
|
sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.1"
|
version: "2.3.2"
|
||||||
hive:
|
hive:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -419,10 +456,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: http
|
name: http
|
||||||
sha256: "761a297c042deedc1ffbb156d6e2af13886bb305c2a343a4d972504cd67dd938"
|
sha256: b9c29a161230ee03d3ccf545097fccd9b87a5264228c5d348202e0f0c28f9010
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.2.1"
|
version: "1.2.2"
|
||||||
http_multi_server:
|
http_multi_server:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -451,10 +488,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: image
|
name: image
|
||||||
sha256: "2237616a36c0d69aef7549ab439b833fb7f9fb9fc861af2cc9ac3eedddd69ca8"
|
sha256: f31d52537dc417fdcde36088fdf11d191026fd5e4fae742491ebd40e5a8bea7d
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.2.0"
|
version: "4.3.0"
|
||||||
infinite_scroll_pagination:
|
infinite_scroll_pagination:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -499,26 +536,26 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: leak_tracker
|
name: leak_tracker
|
||||||
sha256: "78eb209deea09858f5269f5a5b02be4049535f568c07b275096836f01ea323fa"
|
sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "10.0.0"
|
version: "10.0.5"
|
||||||
leak_tracker_flutter_testing:
|
leak_tracker_flutter_testing:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: leak_tracker_flutter_testing
|
name: leak_tracker_flutter_testing
|
||||||
sha256: b46c5e37c19120a8a01918cfaf293547f47269f7cb4b0058f21531c2465d6ef0
|
sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.1"
|
version: "3.0.5"
|
||||||
leak_tracker_testing:
|
leak_tracker_testing:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: leak_tracker_testing
|
name: leak_tracker_testing
|
||||||
sha256: a597f72a664dbd293f3bfc51f9ba69816f84dcd403cdac7066cb3f6003f3ab47
|
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.1"
|
version: "3.0.1"
|
||||||
lints:
|
lints:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -531,10 +568,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: logging
|
name: logging
|
||||||
sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340"
|
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.2.0"
|
version: "1.3.0"
|
||||||
|
macros:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: macros
|
||||||
|
sha256: "0acaed5d6b7eab89f63350bccd82119e6c602df0f391260d0e32b5e23db79536"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.1.2-main.4"
|
||||||
matcher:
|
matcher:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -547,26 +592,34 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: material_color_utilities
|
name: material_color_utilities
|
||||||
sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a"
|
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.8.0"
|
version: "0.11.1"
|
||||||
meta:
|
meta:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: d584fa6707a52763a52446f02cc621b077888fb63b93bbcb1143a7be5a0c0c04
|
sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.11.0"
|
version: "1.15.0"
|
||||||
mime:
|
mime:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: mime
|
name: mime
|
||||||
sha256: "2e123074287cc9fd6c09de8336dae606d1ddb88d9ac47358826db698c176a1f2"
|
sha256: "801fd0b26f14a4a58ccb09d5892c3fbdeff209594300a542492cf13fba9d247a"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.5"
|
version: "1.0.6"
|
||||||
|
mobile_scanner:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: mobile_scanner
|
||||||
|
sha256: e93461298494a3e5475dd2b41068012823b8fe2caf8d47ba545faca2aa3767d6
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.0.1"
|
||||||
nested:
|
nested:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -587,20 +640,20 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: package_info_plus
|
name: package_info_plus
|
||||||
sha256: b93d8b4d624b4ea19b0a5a208b2d6eff06004bc3ce74c06040b120eeadd00ce0
|
sha256: df3eb3e0aed5c1107bb0fdb80a8e82e778114958b1c5ac5644fb1ac9cae8a998
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "8.0.0"
|
version: "8.1.0"
|
||||||
package_info_plus_platform_interface:
|
package_info_plus_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: package_info_plus_platform_interface
|
name: package_info_plus_platform_interface
|
||||||
sha256: f49918f3433a3146047372f9d4f1f847511f2acd5cd030e1f44fe5a50036b70e
|
sha256: ac1f4a4847f1ade8e6a87d1f39f5d7c67490738642e2542f559ec38c37489a66
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.0"
|
version: "3.0.1"
|
||||||
path:
|
path:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: path
|
name: path
|
||||||
sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af"
|
sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af"
|
||||||
@@ -611,18 +664,18 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: path_provider
|
name: path_provider
|
||||||
sha256: c9e7d3a4cd1410877472158bee69963a4579f78b68c65a2b7d40d1a7a88bb161
|
sha256: fec0d61223fba3154d87759e3cc27fe2c8dc498f6386c6d6fc80d1afdd1bf378
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.3"
|
version: "2.1.4"
|
||||||
path_provider_android:
|
path_provider_android:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: path_provider_android
|
name: path_provider_android
|
||||||
sha256: a248d8146ee5983446bf03ed5ea8f6533129a12b11f12057ad1b4a67a2b3b41d
|
sha256: c464428172cb986b758c6d1724c603097febb8fb855aa265aeecc9280c294d4a
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.4"
|
version: "2.2.12"
|
||||||
path_provider_foundation:
|
path_provider_foundation:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -651,10 +704,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: path_provider_windows
|
name: path_provider_windows
|
||||||
sha256: "8bc9f22eee8690981c22aa7fc602f5c85b497a6fb2ceb35ee5a5e5ed85ad8170"
|
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.1"
|
version: "2.3.0"
|
||||||
pausable_timer:
|
pausable_timer:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -675,10 +728,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: platform
|
name: platform
|
||||||
sha256: "9b71283fc13df574056616011fb138fd3b793ea47cc509c189a6c3fa5f8a1a65"
|
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.5"
|
version: "3.1.6"
|
||||||
plugin_platform_interface:
|
plugin_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -723,10 +776,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: qr
|
name: qr
|
||||||
sha256: "64957a3930367bf97cc211a5af99551d630f2f4625e38af10edd6b19131b64b3"
|
sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.1"
|
version: "3.0.2"
|
||||||
qr_flutter:
|
qr_flutter:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -755,58 +808,58 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: shared_preferences
|
name: shared_preferences
|
||||||
sha256: d3bbe5553a986e83980916ded2f0b435ef2e1893dfaa29d5a7a790d0eca12180
|
sha256: "746e5369a43170c25816cc472ee016d3a66bc13fcf430c0bc41ad7b4b2922051"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.3"
|
version: "2.3.2"
|
||||||
shared_preferences_android:
|
shared_preferences_android:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: shared_preferences_android
|
name: shared_preferences_android
|
||||||
sha256: "1ee8bf911094a1b592de7ab29add6f826a7331fb854273d55918693d5364a1f2"
|
sha256: "3b9febd815c9ca29c9e3520d50ec32f49157711e143b7a4ca039eb87e8ade5ab"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.2"
|
version: "2.3.3"
|
||||||
shared_preferences_foundation:
|
shared_preferences_foundation:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: shared_preferences_foundation
|
name: shared_preferences_foundation
|
||||||
sha256: "0a8a893bf4fd1152f93fec03a415d11c27c74454d96e2318a7ac38dd18683ab7"
|
sha256: "07e050c7cd39bad516f8d64c455f04508d09df104be326d8c02551590a0d513d"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.0"
|
version: "2.5.3"
|
||||||
shared_preferences_linux:
|
shared_preferences_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: shared_preferences_linux
|
name: shared_preferences_linux
|
||||||
sha256: "9f2cbcf46d4270ea8be39fa156d86379077c8a5228d9dfdb1164ae0bb93f1faa"
|
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.2"
|
version: "2.4.1"
|
||||||
shared_preferences_platform_interface:
|
shared_preferences_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: shared_preferences_platform_interface
|
name: shared_preferences_platform_interface
|
||||||
sha256: "22e2ecac9419b4246d7c22bfbbda589e3acf5c0351137d87dd2939d984d37c3b"
|
sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.2"
|
version: "2.4.1"
|
||||||
shared_preferences_web:
|
shared_preferences_web:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: shared_preferences_web
|
name: shared_preferences_web
|
||||||
sha256: "9aee1089b36bd2aafe06582b7d7817fd317ef05fc30e6ba14bff247d0933042a"
|
sha256: d2ca4132d3946fec2184261726b355836a82c33d7d5b67af32692aff18a4684e
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.0"
|
version: "2.4.2"
|
||||||
shared_preferences_windows:
|
shared_preferences_windows:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: shared_preferences_windows
|
name: shared_preferences_windows
|
||||||
sha256: "841ad54f3c8381c480d0c9b508b89a34036f512482c407e6df7a9c4aa2ef8f59"
|
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.2"
|
version: "2.4.1"
|
||||||
shelf:
|
shelf:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -819,10 +872,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: shelf_web_socket
|
name: shelf_web_socket
|
||||||
sha256: "9ca081be41c60190ebcb4766b2486a7d50261db7bd0f5d9615f2d653637a84c1"
|
sha256: "073c147238594ecd0d193f3456a5fe91c4b0abbcc68bf5cd95b36c4e194ac611"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.4"
|
version: "2.0.0"
|
||||||
sky_engine:
|
sky_engine:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -912,10 +965,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b"
|
sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.6.1"
|
version: "0.7.2"
|
||||||
|
timezone:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: timezone
|
||||||
|
sha256: "2236ec079a174ce07434e89fcd3fcda430025eb7692244139a9cf54fdcf1fc7d"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.9.4"
|
||||||
timing:
|
timing:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -928,58 +989,58 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: toastification
|
name: toastification
|
||||||
sha256: "5e751acc2fb5b8d008138dac255d62290fde4e5a24824f29809ac098c3dfe395"
|
sha256: "4d97fbfa463dfe83691044cba9f37cb185a79bb9205cfecb655fa1f6be126a13"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.0"
|
version: "2.3.0"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: typed_data
|
name: typed_data
|
||||||
sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c
|
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.3.2"
|
version: "1.4.0"
|
||||||
url_launcher:
|
url_launcher:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: url_launcher
|
name: url_launcher
|
||||||
sha256: "21b704ce5fa560ea9f3b525b43601c678728ba46725bab9b01187b4831377ed3"
|
sha256: "9d06212b1362abc2f0f0d78e6f09f726608c74e3b9462e8368bb03314aa8d603"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.3.0"
|
version: "6.3.1"
|
||||||
url_launcher_android:
|
url_launcher_android:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: url_launcher_android
|
name: url_launcher_android
|
||||||
sha256: "17cd5e205ea615e2c6ea7a77323a11712dffa0720a8a90540db57a01347f9ad9"
|
sha256: "8fc3bae0b68c02c47c5c86fa8bfa74471d42687b0eded01b78de87872db745e2"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.3.2"
|
version: "6.3.12"
|
||||||
url_launcher_ios:
|
url_launcher_ios:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: url_launcher_ios
|
name: url_launcher_ios
|
||||||
sha256: "7068716403343f6ba4969b4173cbf3b84fc768042124bc2c011e5d782b24fe89"
|
sha256: e43b677296fadce447e987a2f519dcf5f6d1e527dc35d01ffab4fff5b8a7063e
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.3.0"
|
version: "6.3.1"
|
||||||
url_launcher_linux:
|
url_launcher_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: url_launcher_linux
|
name: url_launcher_linux
|
||||||
sha256: ab360eb661f8879369acac07b6bb3ff09d9471155357da8443fd5d3cf7363811
|
sha256: e2b9622b4007f97f504cd64c0128309dfb978ae66adbe944125ed9e1750f06af
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.1"
|
version: "3.2.0"
|
||||||
url_launcher_macos:
|
url_launcher_macos:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: url_launcher_macos
|
name: url_launcher_macos
|
||||||
sha256: "9a1a42d5d2d95400c795b2914c36fdcb525870c752569438e4ebb09a2b5d90de"
|
sha256: "769549c999acdb42b8bcfa7c43d72bf79a382ca7441ab18a808e101149daf672"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.2.0"
|
version: "3.2.1"
|
||||||
url_launcher_platform_interface:
|
url_launcher_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -992,26 +1053,26 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: url_launcher_web
|
name: url_launcher_web
|
||||||
sha256: "8d9e750d8c9338601e709cd0885f95825086bd8b642547f26bda435aade95d8a"
|
sha256: "772638d3b34c779ede05ba3d38af34657a05ac55b06279ea6edd409e323dca8e"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.1"
|
version: "2.3.3"
|
||||||
url_launcher_windows:
|
url_launcher_windows:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: url_launcher_windows
|
name: url_launcher_windows
|
||||||
sha256: ecf9725510600aa2bb6d7ddabe16357691b6d2805f66216a97d1b881e21beff7
|
sha256: "44cf3aabcedde30f2dba119a9dea3b0f2672fbe6fa96e85536251d678216b3c4"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.1"
|
version: "3.1.3"
|
||||||
uuid:
|
uuid:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: uuid
|
name: uuid
|
||||||
sha256: "814e9e88f21a176ae1359149021870e87f7cddaf633ab678a5d2b0bff7fd1ba8"
|
sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.4.0"
|
version: "4.5.1"
|
||||||
vector_math:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1024,10 +1085,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: vm_service
|
name: vm_service
|
||||||
sha256: b3d56ff4341b8f182b96aceb2fa20e3dcb336b9f867bc0eafc0de10f1048e957
|
sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "13.0.0"
|
version: "14.2.5"
|
||||||
watcher:
|
watcher:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1044,38 +1105,46 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.5.1"
|
version: "0.5.1"
|
||||||
|
web_socket:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: web_socket
|
||||||
|
sha256: "3c12d96c0c9a4eec095246debcea7b86c0324f22df69893d538fcc6f1b8cce83"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.1.6"
|
||||||
web_socket_channel:
|
web_socket_channel:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: web_socket_channel
|
name: web_socket_channel
|
||||||
sha256: "58c6666b342a38816b2e7e50ed0f1e261959630becd4c879c4f26bfa14aa5a42"
|
sha256: "9f187088ed104edd8662ca07af4b124465893caf063ba29758f97af57e61da8f"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.5"
|
version: "3.0.1"
|
||||||
win32:
|
win32:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: win32
|
name: win32
|
||||||
sha256: "0eaf06e3446824099858367950a813472af675116bf63f008a4c2a75ae13e9cb"
|
sha256: "2294c64768987ea280b43a3d8357d42d5679f3e2b5b69b602be45b2abbd165b0"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.5.0"
|
version: "5.6.1"
|
||||||
win32_registry:
|
win32_registry:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: win32_registry
|
name: win32_registry
|
||||||
sha256: "10589e0d7f4e053f2c61023a31c9ce01146656a70b7b7f0828c0b46d7da2a9bb"
|
sha256: "21ec76dfc731550fd3e2ce7a33a9ea90b828fdf19a5c3bcf556fa992cfa99852"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.3"
|
version: "1.1.5"
|
||||||
xdg_directories:
|
xdg_directories:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: xdg_directories
|
name: xdg_directories
|
||||||
sha256: faea9dee56b520b55a566385b84f2e8de55e7496104adada9962e0bd11bcff1d
|
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.4"
|
version: "1.1.0"
|
||||||
xid:
|
xid:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -1101,5 +1170,5 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.2"
|
version: "3.1.2"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.3.0 <4.0.0"
|
dart: ">=3.5.0 <4.0.0"
|
||||||
flutter: ">=3.19.0"
|
flutter: ">=3.24.0"
|
||||||
|
@@ -33,8 +33,11 @@ dependencies:
|
|||||||
toastification: ^2.0.0
|
toastification: ^2.0.0
|
||||||
uuid: ^4.4.0
|
uuid: ^4.4.0
|
||||||
share_plus: ^9.0.0
|
share_plus: ^9.0.0
|
||||||
|
flutter_local_notifications: ^17.1.2
|
||||||
|
|
||||||
|
|
||||||
|
path: any
|
||||||
|
mobile_scanner: ^6.0.1
|
||||||
dependency_overrides:
|
dependency_overrides:
|
||||||
font_awesome_flutter:
|
font_awesome_flutter:
|
||||||
path: deps/font_awesome_flutter
|
path: deps/font_awesome_flutter
|
||||||
|
@@ -5,7 +5,7 @@ PORT=9090
|
|||||||
NAMESPACE=$(shell git rev-parse --abbrev-ref HEAD)
|
NAMESPACE=$(shell git rev-parse --abbrev-ref HEAD)
|
||||||
HASH=$(shell git rev-parse HEAD)
|
HASH=$(shell git rev-parse HEAD)
|
||||||
|
|
||||||
TAGS="timetzdata sqlite_fts5 sqlite_foreign_keys"
|
TAGS="timetzdata"
|
||||||
|
|
||||||
.PHONY: test swagger pygmentize docker migrate dgi pygmentize lint docker
|
.PHONY: test swagger pygmentize docker migrate dgi pygmentize lint docker
|
||||||
|
|
||||||
|
@@ -411,13 +411,13 @@ func (h APIHandler) ListChannelMessages(pctx ginext.PreContext) ginext.HTTPRespo
|
|||||||
type query struct {
|
type query struct {
|
||||||
PageSize *int `json:"page_size" form:"page_size"`
|
PageSize *int `json:"page_size" form:"page_size"`
|
||||||
NextPageToken *string `json:"next_page_token" form:"next_page_token"`
|
NextPageToken *string `json:"next_page_token" form:"next_page_token"`
|
||||||
Filter *string `json:"filter" form:"filter"`
|
|
||||||
Trimmed *bool `json:"trimmed" form:"trimmed"`
|
Trimmed *bool `json:"trimmed" form:"trimmed"`
|
||||||
}
|
}
|
||||||
type response struct {
|
type response struct {
|
||||||
Messages []models.Message `json:"messages"`
|
Messages []models.Message `json:"messages"`
|
||||||
NextPageToken string `json:"next_page_token"`
|
NextPageToken string `json:"next_page_token"`
|
||||||
PageSize int `json:"page_size"`
|
PageSize int `json:"page_size"`
|
||||||
|
TotalCount int64 `json:"total_count"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var u uri
|
var u uri
|
||||||
@@ -457,16 +457,16 @@ func (h APIHandler) ListChannelMessages(pctx ginext.PreContext) ginext.HTTPRespo
|
|||||||
ChannelID: langext.Ptr([]models.ChannelID{channel.ChannelID}),
|
ChannelID: langext.Ptr([]models.ChannelID{channel.ChannelID}),
|
||||||
}
|
}
|
||||||
|
|
||||||
messages, npt, err := h.database.ListMessages(ctx, filter, &pageSize, tok)
|
messages, npt, totalCount, err := h.database.ListMessages(ctx, filter, &pageSize, tok)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query messages", err)
|
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query messages", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if trimmed {
|
if trimmed {
|
||||||
res := langext.ArrMap(messages, func(v models.Message) models.Message { return v.Trim() })
|
res := langext.ArrMap(messages, func(v models.Message) models.Message { return v.Trim() })
|
||||||
return finishSuccess(ginext.JSON(http.StatusOK, response{Messages: res, NextPageToken: npt.Token(), PageSize: pageSize}))
|
return finishSuccess(ginext.JSON(http.StatusOK, response{Messages: res, NextPageToken: npt.Token(), PageSize: pageSize, TotalCount: totalCount}))
|
||||||
} else {
|
} else {
|
||||||
return finishSuccess(ginext.JSON(http.StatusOK, response{Messages: messages, NextPageToken: npt.Token(), PageSize: pageSize}))
|
return finishSuccess(ginext.JSON(http.StatusOK, response{Messages: messages, NextPageToken: npt.Token(), PageSize: pageSize, TotalCount: totalCount}))
|
||||||
}
|
}
|
||||||
|
|
||||||
})
|
})
|
||||||
|
@@ -39,7 +39,8 @@ func (h APIHandler) ListMessages(pctx ginext.PreContext) ginext.HTTPResponse {
|
|||||||
type query struct {
|
type query struct {
|
||||||
PageSize *int `json:"page_size" form:"page_size"`
|
PageSize *int `json:"page_size" form:"page_size"`
|
||||||
NextPageToken *string `json:"next_page_token" form:"next_page_token"`
|
NextPageToken *string `json:"next_page_token" form:"next_page_token"`
|
||||||
Filter *string `json:"filter" form:"filter"`
|
Search []string `json:"search" form:"search"`
|
||||||
|
StringSearch []string `json:"string_search" form:"string_search"`
|
||||||
Trimmed *bool `json:"trimmed" form:"trimmed"`
|
Trimmed *bool `json:"trimmed" form:"trimmed"`
|
||||||
Channels []string `json:"channel" form:"channel"`
|
Channels []string `json:"channel" form:"channel"`
|
||||||
ChannelIDs []string `json:"channel_id" form:"channel_id"`
|
ChannelIDs []string `json:"channel_id" form:"channel_id"`
|
||||||
@@ -48,11 +49,13 @@ func (h APIHandler) ListMessages(pctx ginext.PreContext) ginext.HTTPResponse {
|
|||||||
TimeAfter *string `json:"after" form:"after"` // RFC3339
|
TimeAfter *string `json:"after" form:"after"` // RFC3339
|
||||||
Priority []int `json:"priority" form:"priority"`
|
Priority []int `json:"priority" form:"priority"`
|
||||||
KeyTokens []string `json:"used_key" form:"used_key"`
|
KeyTokens []string `json:"used_key" form:"used_key"`
|
||||||
|
HasSender *bool `json:"has_sender" form:"has_sender"`
|
||||||
}
|
}
|
||||||
type response struct {
|
type response struct {
|
||||||
Messages []models.Message `json:"messages"`
|
Messages []models.Message `json:"messages"`
|
||||||
NextPageToken string `json:"next_page_token"`
|
NextPageToken string `json:"next_page_token"`
|
||||||
PageSize int `json:"page_size"`
|
PageSize int `json:"page_size"`
|
||||||
|
TotalCount int64 `json:"total_count"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var q query
|
var q query
|
||||||
@@ -90,8 +93,12 @@ func (h APIHandler) ListMessages(pctx ginext.PreContext) ginext.HTTPResponse {
|
|||||||
ConfirmedSubscriptionBy: langext.Ptr(userid),
|
ConfirmedSubscriptionBy: langext.Ptr(userid),
|
||||||
}
|
}
|
||||||
|
|
||||||
if q.Filter != nil && strings.TrimSpace(*q.Filter) != "" {
|
if len(q.Search) != 0 {
|
||||||
filter.SearchString = langext.Ptr([]string{strings.TrimSpace(*q.Filter)})
|
filter.SearchStringFTS = langext.Ptr(langext.ArrMap(q.Search, func(v string) string { return strings.TrimSpace(v) }))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(q.StringSearch) != 0 {
|
||||||
|
filter.SearchStringPlain = langext.Ptr(langext.ArrMap(q.StringSearch, func(v string) string { return strings.TrimSpace(v) }))
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(q.Channels) != 0 {
|
if len(q.Channels) != 0 {
|
||||||
@@ -114,6 +121,10 @@ func (h APIHandler) ListMessages(pctx ginext.PreContext) ginext.HTTPResponse {
|
|||||||
filter.SenderNameCS = langext.Ptr(q.Senders)
|
filter.SenderNameCS = langext.Ptr(q.Senders)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if q.HasSender != nil {
|
||||||
|
filter.HasSenderName = langext.Ptr(*q.HasSender)
|
||||||
|
}
|
||||||
|
|
||||||
if q.TimeBefore != nil {
|
if q.TimeBefore != nil {
|
||||||
t0, err := time.Parse(time.RFC3339, *q.TimeBefore)
|
t0, err := time.Parse(time.RFC3339, *q.TimeBefore)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -146,17 +157,17 @@ func (h APIHandler) ListMessages(pctx ginext.PreContext) ginext.HTTPResponse {
|
|||||||
filter.UsedKeyID = &tids
|
filter.UsedKeyID = &tids
|
||||||
}
|
}
|
||||||
|
|
||||||
messages, npt, err := h.database.ListMessages(ctx, filter, &pageSize, tok)
|
messages, npt, totalCount, err := h.database.ListMessages(ctx, filter, &pageSize, tok)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query messages", err)
|
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query messages", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if trimmed {
|
if trimmed {
|
||||||
res := langext.ArrMap(messages, func(v models.Message) models.Message { return v.PreMarshal().Trim() })
|
res := langext.ArrMap(messages, func(v models.Message) models.Message { return v.PreMarshal().Trim() })
|
||||||
return finishSuccess(ginext.JSON(http.StatusOK, response{Messages: res, NextPageToken: npt.Token(), PageSize: pageSize}))
|
return finishSuccess(ginext.JSON(http.StatusOK, response{Messages: res, NextPageToken: npt.Token(), PageSize: pageSize, TotalCount: totalCount}))
|
||||||
} else {
|
} else {
|
||||||
res := langext.ArrMap(messages, func(v models.Message) models.Message { return v.PreMarshal() })
|
res := langext.ArrMap(messages, func(v models.Message) models.Message { return v.PreMarshal() })
|
||||||
return finishSuccess(ginext.JSON(http.StatusOK, response{Messages: res, NextPageToken: npt.Token(), PageSize: pageSize}))
|
return finishSuccess(ginext.JSON(http.StatusOK, response{Messages: res, NextPageToken: npt.Token(), PageSize: pageSize, TotalCount: totalCount}))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
102
scnserver/api/handler/apiSenderNames.go
Normal file
102
scnserver/api/handler/apiSenderNames.go
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"blackforestbytes.com/simplecloudnotifier/api/apierr"
|
||||||
|
"blackforestbytes.com/simplecloudnotifier/api/ginresp"
|
||||||
|
"blackforestbytes.com/simplecloudnotifier/logic"
|
||||||
|
"blackforestbytes.com/simplecloudnotifier/models"
|
||||||
|
"gogs.mikescher.com/BlackForestBytes/goext/ginext"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListUserSenderNames swaggerdoc
|
||||||
|
//
|
||||||
|
// @Summary List sender-names (of allthe messages of this user)
|
||||||
|
// @ID api-usersendernames-list
|
||||||
|
// @Tags API-v2
|
||||||
|
//
|
||||||
|
// @Param uid path string true "UserID"
|
||||||
|
//
|
||||||
|
// @Success 200 {object} handler.ListUserKeys.response
|
||||||
|
// @Failure 400 {object} ginresp.apiError "supplied values/parameters cannot be parsed / are invalid"
|
||||||
|
// @Failure 401 {object} ginresp.apiError "user is not authorized / has missing permissions"
|
||||||
|
// @Failure 404 {object} ginresp.apiError "message not found"
|
||||||
|
// @Failure 500 {object} ginresp.apiError "internal server error"
|
||||||
|
//
|
||||||
|
// @Router /api/v2/users/{uid}/keys [GET]
|
||||||
|
func (h APIHandler) ListUserSenderNames(pctx ginext.PreContext) ginext.HTTPResponse {
|
||||||
|
type uri struct {
|
||||||
|
UserID models.UserID `uri:"uid" binding:"entityid"`
|
||||||
|
}
|
||||||
|
type response struct {
|
||||||
|
SenderNames []models.SenderNameStatistics `json:"sender_names"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var u uri
|
||||||
|
ctx, g, errResp := pctx.URI(&u).Start()
|
||||||
|
if errResp != nil {
|
||||||
|
return *errResp
|
||||||
|
}
|
||||||
|
defer ctx.Cancel()
|
||||||
|
|
||||||
|
return h.app.DoRequest(ctx, g, models.TLockRead, func(ctx *logic.AppContext, finishSuccess func(r ginext.HTTPResponse) ginext.HTTPResponse) ginext.HTTPResponse {
|
||||||
|
|
||||||
|
if permResp := ctx.CheckPermissionUserRead(u.UserID); permResp != nil {
|
||||||
|
return *permResp
|
||||||
|
}
|
||||||
|
|
||||||
|
names, err := h.database.ListSenderNames(ctx, u.UserID, false)
|
||||||
|
if err != nil {
|
||||||
|
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query messages", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return finishSuccess(ginext.JSON(http.StatusOK, response{SenderNames: names}))
|
||||||
|
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListSenderNames swaggerdoc
|
||||||
|
//
|
||||||
|
// @Summary List sender-names (of all messages this user can view, eitehr own or foreign-subscribed)
|
||||||
|
// @ID api-sendernames-list
|
||||||
|
// @Tags API-v2
|
||||||
|
//
|
||||||
|
// @Success 200 {object} handler.ListSenderNames.response
|
||||||
|
// @Failure 400 {object} ginresp.apiError "supplied values/parameters cannot be parsed / are invalid"
|
||||||
|
// @Failure 401 {object} ginresp.apiError "user is not authorized / has missing permissions"
|
||||||
|
// @Failure 404 {object} ginresp.apiError "message not found"
|
||||||
|
// @Failure 500 {object} ginresp.apiError "internal server error"
|
||||||
|
//
|
||||||
|
// @Router /api/v2/sender-names [GET]
|
||||||
|
func (h APIHandler) ListSenderNames(pctx ginext.PreContext) ginext.HTTPResponse {
|
||||||
|
type response struct {
|
||||||
|
SenderNames []models.SenderNameStatistics `json:"sender_names"`
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, g, errResp := pctx.Start()
|
||||||
|
if errResp != nil {
|
||||||
|
return *errResp
|
||||||
|
}
|
||||||
|
defer ctx.Cancel()
|
||||||
|
|
||||||
|
return h.app.DoRequest(ctx, g, models.TLockRead, func(ctx *logic.AppContext, finishSuccess func(r ginext.HTTPResponse) ginext.HTTPResponse) ginext.HTTPResponse {
|
||||||
|
|
||||||
|
if permResp := ctx.CheckPermissionAny(); permResp != nil {
|
||||||
|
return *permResp
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := *ctx.GetPermissionUserID()
|
||||||
|
|
||||||
|
if permResp := ctx.CheckPermissionUserRead(userID); permResp != nil {
|
||||||
|
return *permResp
|
||||||
|
}
|
||||||
|
|
||||||
|
names, err := h.database.ListSenderNames(ctx, userID, true)
|
||||||
|
if err != nil {
|
||||||
|
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query messages", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return finishSuccess(ginext.JSON(http.StatusOK, response{SenderNames: names}))
|
||||||
|
|
||||||
|
})
|
||||||
|
}
|
@@ -80,7 +80,7 @@ func (h APIHandler) CreateUser(pctx ginext.PreContext) ginext.HTTPResponse {
|
|||||||
sendKey := h.app.GenerateRandomAuthKey()
|
sendKey := h.app.GenerateRandomAuthKey()
|
||||||
adminKey := h.app.GenerateRandomAuthKey()
|
adminKey := h.app.GenerateRandomAuthKey()
|
||||||
|
|
||||||
err := h.database.ClearFCMTokens(ctx, b.FCMToken)
|
err := h.database.DeleteClientsByFCM(ctx, b.FCMToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to clear existing fcm tokens", err)
|
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to clear existing fcm tokens", err)
|
||||||
}
|
}
|
||||||
|
@@ -9,7 +9,6 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/mattn/go-sqlite3"
|
|
||||||
"gogs.mikescher.com/BlackForestBytes/goext/ginext"
|
"gogs.mikescher.com/BlackForestBytes/goext/ginext"
|
||||||
"gogs.mikescher.com/BlackForestBytes/goext/langext"
|
"gogs.mikescher.com/BlackForestBytes/goext/langext"
|
||||||
"gogs.mikescher.com/BlackForestBytes/goext/timeext"
|
"gogs.mikescher.com/BlackForestBytes/goext/timeext"
|
||||||
@@ -91,10 +90,9 @@ func (h CommonHandler) Ping(pctx ginext.PreContext) ginext.HTTPResponse {
|
|||||||
// @Router /api/db-test [post]
|
// @Router /api/db-test [post]
|
||||||
func (h CommonHandler) DatabaseTest(pctx ginext.PreContext) ginext.HTTPResponse {
|
func (h CommonHandler) DatabaseTest(pctx ginext.PreContext) ginext.HTTPResponse {
|
||||||
type response struct {
|
type response struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
LibVersion string `json:"libVersion"`
|
LibVersion string `json:"libVersion"`
|
||||||
LibVersionNumber int `json:"libVersionNumber"`
|
SourceID string `json:"sourceID"`
|
||||||
SourceID string `json:"sourceID"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, g, errResp := pctx.Start()
|
ctx, g, errResp := pctx.Start()
|
||||||
@@ -105,18 +103,20 @@ func (h CommonHandler) DatabaseTest(pctx ginext.PreContext) ginext.HTTPResponse
|
|||||||
|
|
||||||
return h.app.DoRequest(ctx, g, models.TLockRead, func(ctx *logic.AppContext, finishSuccess func(r ginext.HTTPResponse) ginext.HTTPResponse) ginext.HTTPResponse {
|
return h.app.DoRequest(ctx, g, models.TLockRead, func(ctx *logic.AppContext, finishSuccess func(r ginext.HTTPResponse) ginext.HTTPResponse) ginext.HTTPResponse {
|
||||||
|
|
||||||
libVersion, libVersionNumber, sourceID := sqlite3.Version()
|
versionStr, sourceID, err := h.app.Database.Primary.Version(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return ginresp.InternalError(err)
|
||||||
|
}
|
||||||
|
|
||||||
err := h.app.Database.Ping(ctx)
|
err = h.app.Database.Ping(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ginresp.InternalError(err)
|
return ginresp.InternalError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return ginext.JSON(http.StatusOK, response{
|
return ginext.JSON(http.StatusOK, response{
|
||||||
Success: true,
|
Success: true,
|
||||||
LibVersion: libVersion,
|
LibVersion: versionStr,
|
||||||
LibVersionNumber: libVersionNumber,
|
SourceID: sourceID,
|
||||||
SourceID: sourceID,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
})
|
})
|
||||||
@@ -145,12 +145,6 @@ func (h CommonHandler) Health(pctx ginext.PreContext) ginext.HTTPResponse {
|
|||||||
|
|
||||||
return h.app.DoRequest(ctx, g, models.TLockReadWrite, func(ctx *logic.AppContext, finishSuccess func(r ginext.HTTPResponse) ginext.HTTPResponse) ginext.HTTPResponse {
|
return h.app.DoRequest(ctx, g, models.TLockReadWrite, func(ctx *logic.AppContext, finishSuccess func(r ginext.HTTPResponse) ginext.HTTPResponse) ginext.HTTPResponse {
|
||||||
|
|
||||||
_, libVersionNumber, _ := sqlite3.Version()
|
|
||||||
|
|
||||||
if libVersionNumber < 3039000 {
|
|
||||||
return ginresp.InternalError(errors.New("sqlite version too low"))
|
|
||||||
}
|
|
||||||
|
|
||||||
tctx := simplectx.CreateSimpleContext(ctx, nil)
|
tctx := simplectx.CreateSimpleContext(ctx, nil)
|
||||||
|
|
||||||
err := h.app.Database.Ping(tctx)
|
err := h.app.Database.Ping(tctx)
|
||||||
|
@@ -189,7 +189,7 @@ func (h CompatHandler) Register(pctx ginext.PreContext) ginext.HTTPResponse {
|
|||||||
|
|
||||||
adminKey := h.app.GenerateRandomAuthKey()
|
adminKey := h.app.GenerateRandomAuthKey()
|
||||||
|
|
||||||
err := h.database.ClearFCMTokens(ctx, *data.FCMToken)
|
err := h.database.DeleteClientsByFCM(ctx, *data.FCMToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ginresp.CompatAPIError(0, "Failed to clear existing fcm tokens")
|
return ginresp.CompatAPIError(0, "Failed to clear existing fcm tokens")
|
||||||
}
|
}
|
||||||
@@ -538,7 +538,7 @@ func (h CompatHandler) Requery(pctx ginext.PreContext) ginext.HTTPResponse {
|
|||||||
CompatAcknowledged: langext.Ptr(false),
|
CompatAcknowledged: langext.Ptr(false),
|
||||||
}
|
}
|
||||||
|
|
||||||
msgs, _, err := h.database.ListMessages(ctx, filter, langext.Ptr(16), ct.Start())
|
msgs, _, _, err := h.database.ListMessages(ctx, filter, langext.Ptr(16), ct.Start())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ginresp.CompatAPIError(0, "Failed to query user")
|
return ginresp.CompatAPIError(0, "Failed to query user")
|
||||||
}
|
}
|
||||||
|
@@ -152,10 +152,14 @@ func (r *Router) Init(e *ginext.GinWrapper) error {
|
|||||||
apiv2.DELETE("/users/:uid/subscriptions/:sid").Handle(r.apiHandler.CancelSubscription)
|
apiv2.DELETE("/users/:uid/subscriptions/:sid").Handle(r.apiHandler.CancelSubscription)
|
||||||
apiv2.PATCH("/users/:uid/subscriptions/:sid").Handle(r.apiHandler.UpdateSubscription)
|
apiv2.PATCH("/users/:uid/subscriptions/:sid").Handle(r.apiHandler.UpdateSubscription)
|
||||||
|
|
||||||
|
apiv2.GET("/users/:uid/sender-names").Handle(r.apiHandler.ListUserSenderNames)
|
||||||
|
|
||||||
apiv2.GET("/messages").Handle(r.apiHandler.ListMessages)
|
apiv2.GET("/messages").Handle(r.apiHandler.ListMessages)
|
||||||
apiv2.GET("/messages/:mid").Handle(r.apiHandler.GetMessage)
|
apiv2.GET("/messages/:mid").Handle(r.apiHandler.GetMessage)
|
||||||
apiv2.DELETE("/messages/:mid").Handle(r.apiHandler.DeleteMessage)
|
apiv2.DELETE("/messages/:mid").Handle(r.apiHandler.DeleteMessage)
|
||||||
|
|
||||||
|
apiv2.GET("/sender-names").Handle(r.apiHandler.ListSenderNames)
|
||||||
|
|
||||||
apiv2.GET("/preview/users/:uid").Handle(r.apiHandler.GetUserPreview)
|
apiv2.GET("/preview/users/:uid").Handle(r.apiHandler.GetUserPreview)
|
||||||
apiv2.GET("/preview/keys/:kid").Handle(r.apiHandler.GetUserKeyPreview)
|
apiv2.GET("/preview/keys/:kid").Handle(r.apiHandler.GetUserKeyPreview)
|
||||||
apiv2.GET("/preview/channels/:cid").Handle(r.apiHandler.GetChannelPreview)
|
apiv2.GET("/preview/channels/:cid").Handle(r.apiHandler.GetChannelPreview)
|
||||||
|
@@ -15,39 +15,46 @@ import (
|
|||||||
func main() {
|
func main() {
|
||||||
exerr.Init(exerr.ErrorPackageConfigInit{})
|
exerr.Init(exerr.ErrorPackageConfigInit{})
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 1011*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
if !langext.InArray("sqlite3", sql.Drivers()) {
|
if !langext.InArray("sqlite3", sql.Drivers()) {
|
||||||
sqlite.RegisterAsSQLITE3()
|
sqlite.RegisterAsSQLITE3()
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println()
|
for key, schemaObj := range langext.AsSortedBy(langext.MapToArr(schema.PrimarySchema), func(v langext.MapEntry[int, schema.Def]) int { return v.Key }) {
|
||||||
|
var h0 string
|
||||||
|
if key == 1 {
|
||||||
|
h0 = "N/A"
|
||||||
|
} else {
|
||||||
|
var err error
|
||||||
|
h0, err = sq.HashGoSqliteSchema(ctx, schemaObj.Value.SQL)
|
||||||
|
if err != nil {
|
||||||
|
h0 = "ERR"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Printf("PrimarySchema [%d] := %s%s\n", schemaObj.Key, h0, langext.Conditional(schemaObj.Key == schema.PrimarySchemaVersion, " (active)", ""))
|
||||||
|
}
|
||||||
|
|
||||||
for i := 2; i <= schema.PrimarySchemaVersion; i++ {
|
fmt.Printf("\n")
|
||||||
h0, err := sq.HashGoSqliteSchema(ctx, schema.PrimarySchema[i].SQL)
|
|
||||||
|
for _, schemaObj := range langext.AsSortedBy(langext.MapToArr(schema.RequestsSchema), func(v langext.MapEntry[int, schema.Def]) int { return v.Key }) {
|
||||||
|
h0, err := sq.HashGoSqliteSchema(ctx, schemaObj.Value.SQL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h0 = "ERR"
|
h0 = "ERR"
|
||||||
}
|
}
|
||||||
fmt.Printf("PrimarySchema%d := %s\n", i, h0)
|
fmt.Printf("RequestsSchema [%d] := %s%s\n", schemaObj.Key, h0, langext.Conditional(schemaObj.Key == schema.RequestsSchemaVersion, " (active)", ""))
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := 1; i <= schema.RequestsSchemaVersion; i++ {
|
fmt.Printf("\n")
|
||||||
h0, err := sq.HashGoSqliteSchema(ctx, schema.RequestsSchema[i].SQL)
|
|
||||||
|
for _, schemaObj := range langext.AsSortedBy(langext.MapToArr(schema.LogsSchema), func(v langext.MapEntry[int, schema.Def]) int { return v.Key }) {
|
||||||
|
h0, err := sq.HashGoSqliteSchema(ctx, schemaObj.Value.SQL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h0 = "ERR"
|
h0 = "ERR"
|
||||||
}
|
}
|
||||||
fmt.Printf("RequestsSchema%d := %s\n", i, h0)
|
fmt.Printf("LogsSchema [%d] := %s%s\n", schemaObj.Key, h0, langext.Conditional(schemaObj.Key == schema.LogsSchemaVersion, " (active)", ""))
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := 1; i <= schema.LogsSchemaVersion; i++ {
|
fmt.Printf("\n")
|
||||||
h0, err := sq.HashGoSqliteSchema(ctx, schema.LogsSchema[i].SQL)
|
|
||||||
if err != nil {
|
|
||||||
h0 = "ERR"
|
|
||||||
}
|
|
||||||
fmt.Printf("LogsSchema%d := %s\n", i, h0)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println()
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@@ -10,6 +10,7 @@ type DatabaseImpl interface {
|
|||||||
|
|
||||||
Migrate(ctx context.Context) error
|
Migrate(ctx context.Context) error
|
||||||
Ping(ctx context.Context) error
|
Ping(ctx context.Context) error
|
||||||
|
Version(ctx context.Context) (string, string, error)
|
||||||
BeginTx(ctx context.Context) (sq.Tx, error)
|
BeginTx(ctx context.Context) (sq.Tx, error)
|
||||||
Stop(ctx context.Context) error
|
Stop(ctx context.Context) error
|
||||||
|
|
||||||
|
@@ -13,15 +13,21 @@ import (
|
|||||||
"github.com/glebarez/go-sqlite"
|
"github.com/glebarez/go-sqlite"
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
|
"gogs.mikescher.com/BlackForestBytes/goext/exerr"
|
||||||
"gogs.mikescher.com/BlackForestBytes/goext/langext"
|
"gogs.mikescher.com/BlackForestBytes/goext/langext"
|
||||||
"gogs.mikescher.com/BlackForestBytes/goext/sq"
|
"gogs.mikescher.com/BlackForestBytes/goext/sq"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Database struct {
|
type Database struct {
|
||||||
db sq.DB
|
db sq.DB
|
||||||
pp *dbtools.DBPreprocessor
|
pp *dbtools.DBPreprocessor
|
||||||
wal bool
|
wal bool
|
||||||
|
name string
|
||||||
|
schemaVersion int
|
||||||
|
schema map[int]schema.Def
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewLogsDatabase(cfg server.Config) (*Database, error) {
|
func NewLogsDatabase(cfg server.Config) (*Database, error) {
|
||||||
@@ -66,7 +72,14 @@ func NewLogsDatabase(cfg server.Config) (*Database, error) {
|
|||||||
|
|
||||||
qqdb.AddListener(pp)
|
qqdb.AddListener(pp)
|
||||||
|
|
||||||
scndb := &Database{db: qqdb, pp: pp, wal: conf.Journal == "WAL"}
|
scndb := &Database{
|
||||||
|
db: qqdb,
|
||||||
|
pp: pp,
|
||||||
|
wal: conf.Journal == "WAL",
|
||||||
|
schemaVersion: schema.LogsSchemaVersion,
|
||||||
|
schema: schema.LogsSchema,
|
||||||
|
name: "logs",
|
||||||
|
}
|
||||||
|
|
||||||
return scndb, nil
|
return scndb, nil
|
||||||
}
|
}
|
||||||
@@ -99,52 +112,51 @@ func (db *Database) Migrate(outerctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if currschema == 0 {
|
if currschema == db.schemaVersion {
|
||||||
schemastr := schema.LogsSchema[schema.LogsSchemaVersion].SQL
|
log.Info().Msgf("Database [%s] is up-to-date (%d == %d)", db.name, currschema, db.schemaVersion)
|
||||||
schemahash := schema.LogsSchema[schema.LogsSchemaVersion].Hash
|
|
||||||
|
|
||||||
_, err = tx.Exec(tctx, schemastr, sq.PP{})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = db.WriteMetaInt(tctx, "schema", int64(schema.LogsSchemaVersion))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = db.WriteMetaString(tctx, "schema_hash", schemahash)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
ppReInit = true
|
|
||||||
|
|
||||||
currschema = schema.LogsSchemaVersion
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if currschema == 1 {
|
for currschema < db.schemaVersion {
|
||||||
schemHashDB, err := sq.HashSqliteDatabase(tctx, tx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
schemaHashMeta, err := db.ReadMetaString(tctx, "schema_hash")
|
if currschema == 0 {
|
||||||
if err != nil {
|
log.Info().Msgf("Migrate database (initialize) [%s] %d -> %d", db.name, currschema, db.schemaVersion)
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if schemHashDB != langext.Coalesce(schemaHashMeta, "") || langext.Coalesce(schemaHashMeta, "") != schema.LogsSchema[currschema].Hash {
|
schemastr := db.schema[db.schemaVersion].SQL
|
||||||
log.Debug().Str("schemHashDB", schemHashDB).Msg("Schema (logs db)")
|
schemahash := db.schema[db.schemaVersion].Hash
|
||||||
log.Debug().Str("schemaHashMeta", langext.Coalesce(schemaHashMeta, "")).Msg("Schema (logs db)")
|
|
||||||
log.Debug().Str("schemaHashAsset", schema.LogsSchema[currschema].Hash).Msg("Schema (logs db)")
|
_, err = tx.Exec(tctx, schemastr, sq.PP{})
|
||||||
return errors.New("database schema does not match (logs db)")
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.WriteMetaInt(tctx, "schema", int64(db.schemaVersion))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.WriteMetaString(tctx, "schema_hash", schemahash)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
ppReInit = true
|
||||||
|
|
||||||
|
currschema = db.schemaVersion
|
||||||
} else {
|
} else {
|
||||||
log.Debug().Str("schemHash", schemHashDB).Msg("Verified Schema consistency (logs db)")
|
log.Info().Msgf("Migrate database [%s] %d -> %d", db.name, currschema, currschema+1)
|
||||||
|
|
||||||
|
err = db.migrateSingle(tctx, tx, currschema, currschema+1)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
currschema = currschema + 1
|
||||||
|
|
||||||
|
ppReInit = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if currschema != schema.LogsSchemaVersion {
|
if currschema != db.schemaVersion {
|
||||||
return errors.New(fmt.Sprintf("Unknown DB schema: %d", currschema))
|
return errors.New(fmt.Sprintf("Unknown DB schema: %d", currschema))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,10 +176,126 @@ func (db *Database) Migrate(outerctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//goland:noinspection SqlConstantCondition,SqlWithoutWhere
|
||||||
|
func (db *Database) migrateSingle(tctx *simplectx.SimpleContext, tx sq.Tx, schemaFrom int, schemaTo int) error {
|
||||||
|
|
||||||
|
if schemaFrom == schemaTo-1 {
|
||||||
|
|
||||||
|
migSQL := db.schema[schemaTo].MigScript
|
||||||
|
if migSQL == "" {
|
||||||
|
return exerr.New(exerr.TypeInternal, fmt.Sprintf("missing %s migration from %d to %d", db.name, schemaFrom, schemaTo)).Build()
|
||||||
|
}
|
||||||
|
|
||||||
|
return db.migrateBySQL(tctx, tx, migSQL, schemaFrom, schemaTo, db.schema[schemaTo].Hash, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
return exerr.New(exerr.TypeInternal, fmt.Sprintf("missing %s migration from %d to %d", db.name, schemaFrom, schemaTo)).Build()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *Database) migrateBySQL(tctx *simplectx.SimpleContext, tx sq.Tx, stmts string, currSchemaVers int, resultSchemVers int, resultHash string, post func(tctx *simplectx.SimpleContext, tx sq.Tx) error) error {
|
||||||
|
|
||||||
|
schemaHashMeta, err := db.ReadMetaString(tctx, "schema_hash")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
schemHashDBBefore, err := sq.HashSqliteDatabase(tctx, tx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if schemHashDBBefore != langext.Coalesce(schemaHashMeta, "") || langext.Coalesce(schemaHashMeta, "") != db.schema[currSchemaVers].Hash {
|
||||||
|
log.Debug().Str("schemHashDB", schemHashDBBefore).Msg("Schema (primary db)")
|
||||||
|
log.Debug().Str("schemaHashMeta", langext.Coalesce(schemaHashMeta, "")).Msg("Schema (primary db)")
|
||||||
|
log.Debug().Str("schemaHashAsset", db.schema[currSchemaVers].Hash).Msg("Schema (primary db)")
|
||||||
|
return errors.New("database schema does not match (primary db)")
|
||||||
|
} else {
|
||||||
|
log.Debug().Str("schemHash", schemHashDBBefore).Msg("Verified Schema consistency (primary db)")
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info().Msgf("Upgrade schema from %d -> %d", currSchemaVers, resultSchemVers)
|
||||||
|
|
||||||
|
_, err = tx.Exec(tctx, stmts, sq.PP{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
schemHashDBAfter, err := sq.HashSqliteDatabase(tctx, tx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if schemHashDBAfter != resultHash {
|
||||||
|
|
||||||
|
schemaDBStr := langext.Must(createSqliteDatabaseSchemaStringFromSQL(tctx, db.schema[resultSchemVers].SQL))
|
||||||
|
resultDBStr := langext.Must(sq.CreateSqliteDatabaseSchemaString(tctx, tx))
|
||||||
|
|
||||||
|
fmt.Printf("========================================= SQL SCHEMA-DUMP STR (CORRECT | FROM COMPILED SCHEMA):%s\n=========================================\n\n", schemaDBStr)
|
||||||
|
fmt.Printf("========================================= SQL SCHEMA-DUMP STR (CURRNET | AFTER MIGRATION):%s\n=========================================\n\n", resultDBStr)
|
||||||
|
|
||||||
|
return fmt.Errorf("database [%s] schema does not match after [%d -> %d] migration (expected: %s | actual: %s)", db.name, currSchemaVers, resultSchemVers, resultHash, schemHashDBBefore)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.WriteMetaInt(tctx, "schema", int64(resultSchemVers))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.WriteMetaString(tctx, "schema_hash", resultHash)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info().Msgf("Upgrade schema from %d -> %d succesfully", currSchemaVers, resultSchemVers)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func createSqliteDatabaseSchemaStringFromSQL(ctx context.Context, schemaStr string) (string, error) {
|
||||||
|
dbdir := os.TempDir()
|
||||||
|
dbfile1 := filepath.Join(dbdir, langext.MustHexUUID()+".sqlite3")
|
||||||
|
defer func() { _ = os.Remove(dbfile1) }()
|
||||||
|
|
||||||
|
err := os.MkdirAll(dbdir, os.ModePerm)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
url := fmt.Sprintf("file:%s?_pragma=journal_mode(%s)&_pragma=timeout(%d)&_pragma=foreign_keys(%s)&_pragma=busy_timeout(%d)", dbfile1, "DELETE", 1000, "true", 1000)
|
||||||
|
|
||||||
|
xdb, err := sqlx.Open("sqlite", url)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
db := sq.NewDB(xdb, sq.DBOptions{})
|
||||||
|
|
||||||
|
_, err = db.Exec(ctx, schemaStr, sq.PP{})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return sq.CreateSqliteDatabaseSchemaString(ctx, db)
|
||||||
|
}
|
||||||
|
|
||||||
func (db *Database) Ping(ctx context.Context) error {
|
func (db *Database) Ping(ctx context.Context) error {
|
||||||
return db.db.Ping(ctx)
|
return db.db.Ping(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (db *Database) Version(ctx context.Context) (string, string, error) {
|
||||||
|
type rt struct {
|
||||||
|
Version string `db:"version"`
|
||||||
|
SourceID string `db:"sourceID"`
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := sq.QuerySingle[rt](ctx, db.db, "SELECT sqlite_version() AS version, sqlite_source_id() AS sourceID", sq.PP{}, sq.SModeFast, sq.Safe)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp.Version, resp.SourceID, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (db *Database) BeginTx(ctx context.Context) (sq.Tx, error) {
|
func (db *Database) BeginTx(ctx context.Context) (sq.Tx, error) {
|
||||||
return db.db.BeginTransaction(ctx, sql.LevelDefault)
|
return db.db.BeginTransaction(ctx, sql.LevelDefault)
|
||||||
}
|
}
|
||||||
|
@@ -31,27 +31,13 @@ func (db *Database) CreateClient(ctx db.TxContext, userid models.UserID, ctype m
|
|||||||
return entity, nil
|
return entity, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *Database) ClearFCMTokens(ctx db.TxContext, fcmtoken string) error {
|
|
||||||
tx, err := ctx.GetOrCreateTransaction(db)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = tx.Exec(ctx, "DELETE FROM clients WHERE fcm_token = :fcm", sq.PP{"fcm": fcmtoken})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (db *Database) ListClients(ctx db.TxContext, userid models.UserID) ([]models.Client, error) {
|
func (db *Database) ListClients(ctx db.TxContext, userid models.UserID) ([]models.Client, error) {
|
||||||
tx, err := ctx.GetOrCreateTransaction(db)
|
tx, err := ctx.GetOrCreateTransaction(db)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return sq.QueryAll[models.Client](ctx, tx, "SELECT * FROM clients WHERE user_id = :uid ORDER BY clients.timestamp_created DESC, clients.client_id ASC", sq.PP{"uid": userid}, sq.SModeExtended, sq.Safe)
|
return sq.QueryAll[models.Client](ctx, tx, "SELECT * FROM clients WHERE deleted=0 AND user_id = :uid ORDER BY clients.timestamp_created DESC, clients.client_id ASC", sq.PP{"uid": userid}, sq.SModeExtended, sq.Safe)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *Database) GetClient(ctx db.TxContext, userid models.UserID, clientid models.ClientID) (models.Client, error) {
|
func (db *Database) GetClient(ctx db.TxContext, userid models.UserID, clientid models.ClientID) (models.Client, error) {
|
||||||
@@ -60,7 +46,19 @@ func (db *Database) GetClient(ctx db.TxContext, userid models.UserID, clientid m
|
|||||||
return models.Client{}, err
|
return models.Client{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return sq.QuerySingle[models.Client](ctx, tx, "SELECT * FROM clients WHERE user_id = :uid AND client_id = :cid LIMIT 1", sq.PP{
|
return sq.QuerySingle[models.Client](ctx, tx, "SELECT * FROM clients WHERE deleted=0 AND user_id = :uid AND client_id = :cid LIMIT 1", sq.PP{
|
||||||
|
"uid": userid,
|
||||||
|
"cid": clientid,
|
||||||
|
}, sq.SModeExtended, sq.Safe)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *Database) GetClientOpt(ctx db.TxContext, userid models.UserID, clientid models.ClientID) (*models.Client, error) {
|
||||||
|
tx, err := ctx.GetOrCreateTransaction(db)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return sq.QuerySingleOpt[models.Client](ctx, tx, "SELECT * FROM clients WHERE deleted=0 AND user_id = :uid AND client_id = :cid LIMIT 1", sq.PP{
|
||||||
"uid": userid,
|
"uid": userid,
|
||||||
"cid": clientid,
|
"cid": clientid,
|
||||||
}, sq.SModeExtended, sq.Safe)
|
}, sq.SModeExtended, sq.Safe)
|
||||||
@@ -72,7 +70,7 @@ func (db *Database) DeleteClient(ctx db.TxContext, clientid models.ClientID) err
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = tx.Exec(ctx, "DELETE FROM clients WHERE client_id = :cid", sq.PP{"cid": clientid})
|
_, err = tx.Exec(ctx, "UPDATE clients SET deleted=1 WHERE deleted=0 AND client_id = :cid", sq.PP{"cid": clientid})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -86,7 +84,7 @@ func (db *Database) DeleteClientsByFCM(ctx db.TxContext, fcmtoken string) error
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = tx.Exec(ctx, "DELETE FROM clients WHERE fcm_token = :fcm", sq.PP{"fcm": fcmtoken})
|
_, err = tx.Exec(ctx, "UPDATE clients SET deleted=1 WHERE deleted=0 AND fcm_token = :fcm", sq.PP{"fcm": fcmtoken})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -100,7 +98,7 @@ func (db *Database) UpdateClientFCMToken(ctx db.TxContext, clientid models.Clien
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = tx.Exec(ctx, "UPDATE clients SET fcm_token = :vvv WHERE client_id = :cid", sq.PP{
|
_, err = tx.Exec(ctx, "UPDATE clients SET fcm_token = :vvv WHERE deleted=0 AND client_id = :cid", sq.PP{
|
||||||
"vvv": fcmtoken,
|
"vvv": fcmtoken,
|
||||||
"cid": clientid,
|
"cid": clientid,
|
||||||
})
|
})
|
||||||
@@ -117,7 +115,7 @@ func (db *Database) UpdateClientAgentModel(ctx db.TxContext, clientid models.Cli
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = tx.Exec(ctx, "UPDATE clients SET agent_model = :vvv WHERE client_id = :cid", sq.PP{
|
_, err = tx.Exec(ctx, "UPDATE clients SET agent_model = :vvv WHERE deleted=0 AND client_id = :cid", sq.PP{
|
||||||
"vvv": agentModel,
|
"vvv": agentModel,
|
||||||
"cid": clientid,
|
"cid": clientid,
|
||||||
})
|
})
|
||||||
@@ -134,7 +132,7 @@ func (db *Database) UpdateClientAgentVersion(ctx db.TxContext, clientid models.C
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = tx.Exec(ctx, "UPDATE clients SET agent_version = :vvv WHERE client_id = :cid", sq.PP{
|
_, err = tx.Exec(ctx, "UPDATE clients SET agent_version = :vvv WHERE deleted=0 AND client_id = :cid", sq.PP{
|
||||||
"vvv": agentVersion,
|
"vvv": agentVersion,
|
||||||
"cid": clientid,
|
"cid": clientid,
|
||||||
})
|
})
|
||||||
@@ -151,7 +149,7 @@ func (db *Database) UpdateClientDescriptionName(ctx db.TxContext, clientid model
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = tx.Exec(ctx, "UPDATE clients SET name = :vvv WHERE client_id = :cid", sq.PP{
|
_, err = tx.Exec(ctx, "UPDATE clients SET name = :vvv WHERE deleted=0 AND client_id = :cid", sq.PP{
|
||||||
"vvv": descriptionName,
|
"vvv": descriptionName,
|
||||||
"cid": clientid,
|
"cid": clientid,
|
||||||
})
|
})
|
||||||
|
@@ -13,15 +13,21 @@ import (
|
|||||||
"github.com/glebarez/go-sqlite"
|
"github.com/glebarez/go-sqlite"
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
|
"gogs.mikescher.com/BlackForestBytes/goext/exerr"
|
||||||
"gogs.mikescher.com/BlackForestBytes/goext/langext"
|
"gogs.mikescher.com/BlackForestBytes/goext/langext"
|
||||||
"gogs.mikescher.com/BlackForestBytes/goext/sq"
|
"gogs.mikescher.com/BlackForestBytes/goext/sq"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Database struct {
|
type Database struct {
|
||||||
db sq.DB
|
db sq.DB
|
||||||
pp *dbtools.DBPreprocessor
|
pp *dbtools.DBPreprocessor
|
||||||
wal bool
|
wal bool
|
||||||
|
name string
|
||||||
|
schemaVersion int
|
||||||
|
schema map[int]schema.Def
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewPrimaryDatabase(cfg server.Config) (*Database, error) {
|
func NewPrimaryDatabase(cfg server.Config) (*Database, error) {
|
||||||
@@ -66,7 +72,14 @@ func NewPrimaryDatabase(cfg server.Config) (*Database, error) {
|
|||||||
|
|
||||||
qqdb.AddListener(pp)
|
qqdb.AddListener(pp)
|
||||||
|
|
||||||
scndb := &Database{db: qqdb, pp: pp, wal: conf.Journal == "WAL"}
|
scndb := &Database{
|
||||||
|
db: qqdb,
|
||||||
|
pp: pp,
|
||||||
|
wal: conf.Journal == "WAL",
|
||||||
|
schemaVersion: schema.PrimarySchemaVersion,
|
||||||
|
schema: schema.PrimarySchema,
|
||||||
|
name: "primary",
|
||||||
|
}
|
||||||
|
|
||||||
return scndb, nil
|
return scndb, nil
|
||||||
}
|
}
|
||||||
@@ -99,151 +112,51 @@ func (db *Database) Migrate(outerctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if currschema == 0 {
|
if currschema == db.schemaVersion {
|
||||||
schemastr := schema.PrimarySchema[schema.PrimarySchemaVersion].SQL
|
log.Info().Msgf("Database [%s] is up-to-date (%d == %d)", db.name, currschema, db.schemaVersion)
|
||||||
schemahash := schema.PrimarySchema[schema.PrimarySchemaVersion].Hash
|
|
||||||
|
|
||||||
_, err = tx.Exec(tctx, schemastr, sq.PP{})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = db.WriteMetaInt(tctx, "schema", int64(schema.PrimarySchemaVersion))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = db.WriteMetaString(tctx, "schema_hash", schemahash)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
ppReInit = true
|
|
||||||
|
|
||||||
currschema = schema.PrimarySchemaVersion
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if currschema == 1 {
|
for currschema < db.schemaVersion {
|
||||||
return errors.New("cannot autom. upgrade schema 1")
|
|
||||||
}
|
|
||||||
|
|
||||||
if currschema == 2 {
|
if currschema == 0 {
|
||||||
return errors.New("cannot autom. upgrade schema 2")
|
log.Info().Msgf("Migrate database (initialize) [%s] %d -> %d", db.name, currschema, db.schemaVersion)
|
||||||
}
|
|
||||||
|
|
||||||
if currschema == 3 {
|
schemastr := db.schema[db.schemaVersion].SQL
|
||||||
|
schemahash := db.schema[db.schemaVersion].Hash
|
||||||
|
|
||||||
schemaHashMeta, err := db.ReadMetaString(tctx, "schema_hash")
|
_, err = tx.Exec(tctx, schemastr, sq.PP{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
schemHashDB, err := sq.HashSqliteDatabase(tctx, tx)
|
err = db.WriteMetaInt(tctx, "schema", int64(db.schemaVersion))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if schemHashDB != langext.Coalesce(schemaHashMeta, "") || langext.Coalesce(schemaHashMeta, "") != schema.PrimarySchema[currschema].Hash {
|
err = db.WriteMetaString(tctx, "schema_hash", schemahash)
|
||||||
log.Debug().Str("schemHashDB", schemHashDB).Msg("Schema (primary db)")
|
if err != nil {
|
||||||
log.Debug().Str("schemaHashMeta", langext.Coalesce(schemaHashMeta, "")).Msg("Schema (primary db)")
|
return err
|
||||||
log.Debug().Str("schemaHashAsset", schema.PrimarySchema[currschema].Hash).Msg("Schema (primary db)")
|
}
|
||||||
return errors.New("database schema does not match (primary db)")
|
|
||||||
|
ppReInit = true
|
||||||
|
|
||||||
|
currschema = db.schemaVersion
|
||||||
} else {
|
} else {
|
||||||
log.Debug().Str("schemHash", schemHashDB).Msg("Verified Schema consistency (primary db)")
|
log.Info().Msgf("Migrate database [%s] %d -> %d", db.name, currschema, currschema+1)
|
||||||
}
|
|
||||||
|
|
||||||
log.Info().Int("currschema", currschema).Msg("Upgrade schema from 3 -> 4")
|
err = db.migrateSingle(tctx, tx, currschema, currschema+1)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
_, err = tx.Exec(tctx, schema.PrimaryMigration_3_4, sq.PP{})
|
currschema = currschema + 1
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
currschema = 4
|
ppReInit = true
|
||||||
|
|
||||||
err = db.WriteMetaInt(tctx, "schema", int64(currschema))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = db.WriteMetaString(tctx, "schema_hash", schema.PrimarySchema[currschema].Hash)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Info().Int("currschema", currschema).Msg("Upgrade schema from 3 -> 4 succesfully")
|
|
||||||
|
|
||||||
ppReInit = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if currschema == 4 {
|
|
||||||
|
|
||||||
schemaHashMeta, err := db.ReadMetaString(tctx, "schema_hash")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
schemHashDB, err := sq.HashSqliteDatabase(tctx, tx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if schemHashDB != langext.Coalesce(schemaHashMeta, "") || langext.Coalesce(schemaHashMeta, "") != schema.PrimarySchema[currschema].Hash {
|
|
||||||
log.Debug().Str("schemHashDB", schemHashDB).Msg("Schema (primary db)")
|
|
||||||
log.Debug().Str("schemaHashMeta", langext.Coalesce(schemaHashMeta, "")).Msg("Schema (primary db)")
|
|
||||||
log.Debug().Str("schemaHashAsset", schema.PrimarySchema[currschema].Hash).Msg("Schema (primary db)")
|
|
||||||
return errors.New("database schema does not match (primary db)")
|
|
||||||
} else {
|
|
||||||
log.Debug().Str("schemHash", schemHashDB).Msg("Verified Schema consistency (primary db)")
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Info().Int("currschema", currschema).Msg("Upgrade schema from 4 -> 5")
|
|
||||||
|
|
||||||
_, err = tx.Exec(tctx, schema.PrimaryMigration_4_5, sq.PP{})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
currschema = 5
|
|
||||||
|
|
||||||
err = db.WriteMetaInt(tctx, "schema", int64(currschema))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = db.WriteMetaString(tctx, "schema_hash", schema.PrimarySchema[currschema].Hash)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Info().Int("currschema", currschema).Msg("Upgrade schema from 4 -> 5 succesfully")
|
|
||||||
|
|
||||||
ppReInit = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if currschema == 5 {
|
|
||||||
|
|
||||||
schemaHashMeta, err := db.ReadMetaString(tctx, "schema_hash")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
schemHashDB, err := sq.HashSqliteDatabase(tctx, tx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if schemHashDB != langext.Coalesce(schemaHashMeta, "") || langext.Coalesce(schemaHashMeta, "") != schema.PrimarySchema[currschema].Hash {
|
|
||||||
log.Debug().Str("schemHashDB", schemHashDB).Msg("Schema (primary db)")
|
|
||||||
log.Debug().Str("schemaHashMeta", langext.Coalesce(schemaHashMeta, "")).Msg("Schema (primary db)")
|
|
||||||
log.Debug().Str("schemaHashAsset", schema.PrimarySchema[currschema].Hash).Msg("Schema (primary db)")
|
|
||||||
return errors.New("database schema does not match (primary db)")
|
|
||||||
} else {
|
|
||||||
log.Debug().Str("schemHash", schemHashDB).Msg("Verified Schema consistency (primary db)")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if currschema != schema.PrimarySchemaVersion {
|
if currschema != db.schemaVersion {
|
||||||
return errors.New(fmt.Sprintf("Unknown DB schema: %d", currschema))
|
return errors.New(fmt.Sprintf("Unknown DB schema: %d", currschema))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,10 +176,126 @@ func (db *Database) Migrate(outerctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//goland:noinspection SqlConstantCondition,SqlWithoutWhere
|
||||||
|
func (db *Database) migrateSingle(tctx *simplectx.SimpleContext, tx sq.Tx, schemaFrom int, schemaTo int) error {
|
||||||
|
|
||||||
|
if schemaFrom == schemaTo-1 {
|
||||||
|
|
||||||
|
migSQL := db.schema[schemaTo].MigScript
|
||||||
|
if migSQL == "" {
|
||||||
|
return exerr.New(exerr.TypeInternal, fmt.Sprintf("missing %s migration from %d to %d", db.name, schemaFrom, schemaTo)).Build()
|
||||||
|
}
|
||||||
|
|
||||||
|
return db.migrateBySQL(tctx, tx, migSQL, schemaFrom, schemaTo, db.schema[schemaTo].Hash, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
return exerr.New(exerr.TypeInternal, fmt.Sprintf("missing %s migration from %d to %d", db.name, schemaFrom, schemaTo)).Build()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *Database) migrateBySQL(tctx *simplectx.SimpleContext, tx sq.Tx, stmts string, currSchemaVers int, resultSchemVers int, resultHash string, post func(tctx *simplectx.SimpleContext, tx sq.Tx) error) error {
|
||||||
|
|
||||||
|
schemaHashMeta, err := db.ReadMetaString(tctx, "schema_hash")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
schemHashDBBefore, err := sq.HashSqliteDatabase(tctx, tx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if schemHashDBBefore != langext.Coalesce(schemaHashMeta, "") || langext.Coalesce(schemaHashMeta, "") != db.schema[currSchemaVers].Hash {
|
||||||
|
log.Debug().Str("schemHashDB", schemHashDBBefore).Msg("Schema (primary db)")
|
||||||
|
log.Debug().Str("schemaHashMeta", langext.Coalesce(schemaHashMeta, "")).Msg("Schema (primary db)")
|
||||||
|
log.Debug().Str("schemaHashAsset", db.schema[currSchemaVers].Hash).Msg("Schema (primary db)")
|
||||||
|
return errors.New("database schema does not match (primary db)")
|
||||||
|
} else {
|
||||||
|
log.Debug().Str("schemHash", schemHashDBBefore).Msg("Verified Schema consistency (primary db)")
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info().Msgf("Upgrade schema from %d -> %d", currSchemaVers, resultSchemVers)
|
||||||
|
|
||||||
|
_, err = tx.Exec(tctx, stmts, sq.PP{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
schemHashDBAfter, err := sq.HashSqliteDatabase(tctx, tx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if schemHashDBAfter != resultHash {
|
||||||
|
|
||||||
|
schemaDBStr := langext.Must(createSqliteDatabaseSchemaStringFromSQL(tctx, db.schema[resultSchemVers].SQL))
|
||||||
|
resultDBStr := langext.Must(sq.CreateSqliteDatabaseSchemaString(tctx, tx))
|
||||||
|
|
||||||
|
fmt.Printf("========================================= SQL SCHEMA-DUMP STR (CORRECT | FROM COMPILED SCHEMA):%s\n=========================================\n\n", schemaDBStr)
|
||||||
|
fmt.Printf("========================================= SQL SCHEMA-DUMP STR (CURRNET | AFTER MIGRATION):%s\n=========================================\n\n", resultDBStr)
|
||||||
|
|
||||||
|
return fmt.Errorf("database [%s] schema does not match after [%d -> %d] migration (expected: %s | actual: %s)", db.name, currSchemaVers, resultSchemVers, resultHash, schemHashDBBefore)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.WriteMetaInt(tctx, "schema", int64(resultSchemVers))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.WriteMetaString(tctx, "schema_hash", resultHash)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info().Msgf("Upgrade schema from %d -> %d succesfully", currSchemaVers, resultSchemVers)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func createSqliteDatabaseSchemaStringFromSQL(ctx context.Context, schemaStr string) (string, error) {
|
||||||
|
dbdir := os.TempDir()
|
||||||
|
dbfile1 := filepath.Join(dbdir, langext.MustHexUUID()+".sqlite3")
|
||||||
|
defer func() { _ = os.Remove(dbfile1) }()
|
||||||
|
|
||||||
|
err := os.MkdirAll(dbdir, os.ModePerm)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
url := fmt.Sprintf("file:%s?_pragma=journal_mode(%s)&_pragma=timeout(%d)&_pragma=foreign_keys(%s)&_pragma=busy_timeout(%d)", dbfile1, "DELETE", 1000, "true", 1000)
|
||||||
|
|
||||||
|
xdb, err := sqlx.Open("sqlite", url)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
db := sq.NewDB(xdb, sq.DBOptions{})
|
||||||
|
|
||||||
|
_, err = db.Exec(ctx, schemaStr, sq.PP{})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return sq.CreateSqliteDatabaseSchemaString(ctx, db)
|
||||||
|
}
|
||||||
|
|
||||||
func (db *Database) Ping(ctx context.Context) error {
|
func (db *Database) Ping(ctx context.Context) error {
|
||||||
return db.db.Ping(ctx)
|
return db.db.Ping(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (db *Database) Version(ctx context.Context) (string, string, error) {
|
||||||
|
type rt struct {
|
||||||
|
Version string `db:"version"`
|
||||||
|
SourceID string `db:"sourceID"`
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := sq.QuerySingle[rt](ctx, db.db, "SELECT sqlite_version() AS version, sqlite_source_id() AS sourceID", sq.PP{}, sq.SModeFast, sq.Safe)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp.Version, resp.SourceID, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (db *Database) BeginTx(ctx context.Context) (sq.Tx, error) {
|
func (db *Database) BeginTx(ctx context.Context) (sq.Tx, error) {
|
||||||
return db.db.BeginTransaction(ctx, sql.LevelDefault)
|
return db.db.BeginTransaction(ctx, sql.LevelDefault)
|
||||||
}
|
}
|
||||||
|
@@ -125,7 +125,7 @@ func (db *Database) SetDeliveryRetry(ctx db.TxContext, delivery models.Delivery)
|
|||||||
}
|
}
|
||||||
|
|
||||||
_, err = tx.Exec(ctx, "UPDATE deliveries SET status = 'RETRY', next_delivery = :next, retry_count = :rc WHERE delivery_id = :did", sq.PP{
|
_, err = tx.Exec(ctx, "UPDATE deliveries SET status = 'RETRY', next_delivery = :next, retry_count = :rc WHERE delivery_id = :did", sq.PP{
|
||||||
"next": scn.NextDeliveryTimestamp(time.Now()),
|
"next": time2DB(scn.NextDeliveryTimestamp(time.Now())),
|
||||||
"rc": delivery.RetryCount + 1,
|
"rc": delivery.RetryCount + 1,
|
||||||
"did": delivery.DeliveryID,
|
"did": delivery.DeliveryID,
|
||||||
})
|
})
|
||||||
|
@@ -80,14 +80,10 @@ func (db *Database) DeleteMessage(ctx db.TxContext, messageID models.MessageID)
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *Database) ListMessages(ctx db.TxContext, filter models.MessageFilter, pageSize *int, inTok ct.CursorToken) ([]models.Message, ct.CursorToken, error) {
|
func (db *Database) ListMessages(ctx db.TxContext, filter models.MessageFilter, pageSize *int, inTok ct.CursorToken) ([]models.Message, ct.CursorToken, int64, error) {
|
||||||
tx, err := ctx.GetOrCreateTransaction(db)
|
tx, err := ctx.GetOrCreateTransaction(db)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, ct.CursorToken{}, err
|
return nil, ct.CursorToken{}, 0, err
|
||||||
}
|
|
||||||
|
|
||||||
if inTok.Mode == ct.CTMEnd {
|
|
||||||
return make([]models.Message, 0), ct.End(), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pageCond := "1=1"
|
pageCond := "1=1"
|
||||||
@@ -105,21 +101,39 @@ func (db *Database) ListMessages(ctx db.TxContext, filter models.MessageFilter,
|
|||||||
orderClause = "ORDER BY COALESCE(timestamp_client, timestamp_real) DESC, message_id DESC"
|
orderClause = "ORDER BY COALESCE(timestamp_client, timestamp_real) DESC, message_id DESC"
|
||||||
}
|
}
|
||||||
|
|
||||||
sqlQuery := "SELECT " + "messages.*" + " FROM messages " + filterJoin + " WHERE ( " + pageCond + " ) AND ( " + filterCond + " ) " + orderClause
|
sqlQueryList := "SELECT " + "messages.*" + " FROM messages " + filterJoin + " WHERE ( " + pageCond + " ) AND ( " + filterCond + " ) " + orderClause
|
||||||
|
sqlQueryCount := "SELECT " + " COUNT(*) AS count FROM messages " + filterJoin + " WHERE ( " + filterCond + " ) "
|
||||||
|
|
||||||
prepParams["tokts"] = inTok.Timestamp
|
prepParams["tokts"] = inTok.Timestamp
|
||||||
prepParams["tokid"] = inTok.Id
|
prepParams["tokid"] = inTok.Id
|
||||||
|
|
||||||
data, err := sq.QueryAll[models.Message](ctx, tx, sqlQuery, prepParams, sq.SModeExtended, sq.Safe)
|
if inTok.Mode == ct.CTMEnd {
|
||||||
if err != nil {
|
|
||||||
return nil, ct.CursorToken{}, err
|
dataCount, err := sq.QuerySingle[CountResponse](ctx, tx, sqlQueryCount, prepParams, sq.SModeFast, sq.Safe)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ct.CursorToken{}, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return make([]models.Message, 0), ct.End(), dataCount.Count, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if pageSize == nil || len(data) <= *pageSize {
|
dataList, err := sq.QueryAll[models.Message](ctx, tx, sqlQueryList, prepParams, sq.SModeExtended, sq.Safe)
|
||||||
return data, ct.End(), nil
|
if err != nil {
|
||||||
|
return nil, ct.CursorToken{}, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if pageSize == nil || len(dataList) <= *pageSize {
|
||||||
|
return dataList, ct.End(), int64(len(dataList)), nil
|
||||||
} else {
|
} else {
|
||||||
outToken := ct.Normal(data[*pageSize-1].Timestamp(), data[*pageSize-1].MessageID.String(), "DESC", filter.Hash())
|
|
||||||
return data[0:*pageSize], outToken, nil
|
dataCount, err := sq.QuerySingle[CountResponse](ctx, tx, sqlQueryCount, prepParams, sq.SModeFast, sq.Safe)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ct.CursorToken{}, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
outToken := ct.Normal(dataList[*pageSize-1].Timestamp(), dataList[*pageSize-1].MessageID.String(), "DESC", filter.Hash())
|
||||||
|
|
||||||
|
return dataList[0:*pageSize], outToken, dataCount.Count, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
26
scnserver/db/impl/primary/senderNames.go
Normal file
26
scnserver/db/impl/primary/senderNames.go
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
package primary
|
||||||
|
|
||||||
|
import (
|
||||||
|
"blackforestbytes.com/simplecloudnotifier/db"
|
||||||
|
"blackforestbytes.com/simplecloudnotifier/models"
|
||||||
|
"gogs.mikescher.com/BlackForestBytes/goext/sq"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (db *Database) ListSenderNames(ctx db.TxContext, userid models.UserID, includeForeignSubscribed bool) ([]models.SenderNameStatistics, error) {
|
||||||
|
tx, err := ctx.GetOrCreateTransaction(db)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var sqlStr string
|
||||||
|
|
||||||
|
prepParams := sq.PP{"uid": userid}
|
||||||
|
|
||||||
|
if includeForeignSubscribed {
|
||||||
|
sqlStr = "SELECT sender_name AS name, MAX(timestamp_real) AS ts_last, MIN(timestamp_real) AS ts_first, COUNT(*) AS count FROM messages LEFT JOIN subscriptions AS subs on messages.channel_id = subs.channel_id WHERE (subs.subscriber_user_id = :uid AND subs.confirmed = 1) AND sender_NAME NOT NULL GROUP BY sender_name ORDER BY ts_last DESC"
|
||||||
|
} else {
|
||||||
|
sqlStr = "SELECT sender_name AS name, MAX(timestamp_real) AS ts_last, MIN(timestamp_real) AS ts_first, COUNT(*) AS count FROM messages WHERE sender_user_id = :uid AND sender_NAME NOT NULL GROUP BY sender_name ORDER BY ts_last DESC"
|
||||||
|
}
|
||||||
|
|
||||||
|
return sq.QueryAll[models.SenderNameStatistics](ctx, tx, sqlStr, prepParams, sq.SModeExtended, sq.Safe)
|
||||||
|
}
|
@@ -62,6 +62,15 @@ func (db *Database) GetUser(ctx db.TxContext, userid models.UserID) (models.User
|
|||||||
return sq.QuerySingle[models.User](ctx, tx, "SELECT * FROM users WHERE user_id = :uid LIMIT 1", sq.PP{"uid": userid}, sq.SModeExtended, sq.Safe)
|
return sq.QuerySingle[models.User](ctx, tx, "SELECT * FROM users WHERE user_id = :uid LIMIT 1", sq.PP{"uid": userid}, sq.SModeExtended, sq.Safe)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (db *Database) GetUserOpt(ctx db.TxContext, userid models.UserID) (*models.User, error) {
|
||||||
|
tx, err := ctx.GetOrCreateTransaction(db)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return sq.QuerySingleOpt[models.User](ctx, tx, "SELECT * FROM users WHERE user_id = :uid LIMIT 1", sq.PP{"uid": userid}, sq.SModeExtended, sq.Safe)
|
||||||
|
}
|
||||||
|
|
||||||
func (db *Database) UpdateUserUsername(ctx db.TxContext, userid models.UserID, username *string) error {
|
func (db *Database) UpdateUserUsername(ctx db.TxContext, userid models.UserID, username *string) error {
|
||||||
tx, err := ctx.GetOrCreateTransaction(db)
|
tx, err := ctx.GetOrCreateTransaction(db)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@@ -23,3 +23,7 @@ func time2DBOpt(t *time.Time) *int64 {
|
|||||||
}
|
}
|
||||||
return langext.Ptr(t.UnixMilli())
|
return langext.Ptr(t.UnixMilli())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CountResponse struct {
|
||||||
|
Count int64 `db:"count"`
|
||||||
|
}
|
||||||
|
@@ -13,15 +13,21 @@ import (
|
|||||||
"github.com/glebarez/go-sqlite"
|
"github.com/glebarez/go-sqlite"
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
|
"gogs.mikescher.com/BlackForestBytes/goext/exerr"
|
||||||
"gogs.mikescher.com/BlackForestBytes/goext/langext"
|
"gogs.mikescher.com/BlackForestBytes/goext/langext"
|
||||||
"gogs.mikescher.com/BlackForestBytes/goext/sq"
|
"gogs.mikescher.com/BlackForestBytes/goext/sq"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Database struct {
|
type Database struct {
|
||||||
db sq.DB
|
db sq.DB
|
||||||
pp *dbtools.DBPreprocessor
|
pp *dbtools.DBPreprocessor
|
||||||
wal bool
|
wal bool
|
||||||
|
name string
|
||||||
|
schemaVersion int
|
||||||
|
schema map[int]schema.Def
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRequestsDatabase(cfg server.Config) (*Database, error) {
|
func NewRequestsDatabase(cfg server.Config) (*Database, error) {
|
||||||
@@ -66,7 +72,14 @@ func NewRequestsDatabase(cfg server.Config) (*Database, error) {
|
|||||||
|
|
||||||
qqdb.AddListener(pp)
|
qqdb.AddListener(pp)
|
||||||
|
|
||||||
scndb := &Database{db: qqdb, pp: pp, wal: conf.Journal == "WAL"}
|
scndb := &Database{
|
||||||
|
db: qqdb,
|
||||||
|
pp: pp,
|
||||||
|
wal: conf.Journal == "WAL",
|
||||||
|
schemaVersion: schema.RequestsSchemaVersion,
|
||||||
|
schema: schema.RequestsSchema,
|
||||||
|
name: "requests",
|
||||||
|
}
|
||||||
|
|
||||||
return scndb, nil
|
return scndb, nil
|
||||||
}
|
}
|
||||||
@@ -99,57 +112,51 @@ func (db *Database) Migrate(outerctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if currschema == 0 {
|
if currschema == db.schemaVersion {
|
||||||
schemastr := schema.RequestsSchema[schema.RequestsSchemaVersion].SQL
|
log.Info().Msgf("Database [%s] is up-to-date (%d == %d)", db.name, currschema, db.schemaVersion)
|
||||||
schemahash := schema.RequestsSchema[schema.RequestsSchemaVersion].Hash
|
|
||||||
|
|
||||||
schemahash, err := sq.HashGoSqliteSchema(tctx, schemastr)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = tx.Exec(tctx, schemastr, sq.PP{})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = db.WriteMetaInt(tctx, "schema", int64(schema.RequestsSchemaVersion))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = db.WriteMetaString(tctx, "schema_hash", schemahash)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
ppReInit = true
|
|
||||||
|
|
||||||
currschema = schema.LogsSchemaVersion
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if currschema == 1 {
|
for currschema < db.schemaVersion {
|
||||||
schemHashDB, err := sq.HashSqliteDatabase(tctx, tx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
schemaHashMeta, err := db.ReadMetaString(tctx, "schema_hash")
|
if currschema == 0 {
|
||||||
if err != nil {
|
log.Info().Msgf("Migrate database (initialize) [%s] %d -> %d", db.name, currschema, db.schemaVersion)
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if schemHashDB != langext.Coalesce(schemaHashMeta, "") || langext.Coalesce(schemaHashMeta, "") != schema.RequestsSchema[currschema].Hash {
|
schemastr := db.schema[db.schemaVersion].SQL
|
||||||
log.Debug().Str("schemHashDB", schemHashDB).Msg("Schema (requests db)")
|
schemahash := db.schema[db.schemaVersion].Hash
|
||||||
log.Debug().Str("schemaHashMeta", langext.Coalesce(schemaHashMeta, "")).Msg("Schema (requests db)")
|
|
||||||
log.Debug().Str("schemaHashAsset", schema.RequestsSchema[currschema].Hash).Msg("Schema (requests db)")
|
_, err = tx.Exec(tctx, schemastr, sq.PP{})
|
||||||
return errors.New("database schema does not match (requests db)")
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.WriteMetaInt(tctx, "schema", int64(db.schemaVersion))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.WriteMetaString(tctx, "schema_hash", schemahash)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
ppReInit = true
|
||||||
|
|
||||||
|
currschema = db.schemaVersion
|
||||||
} else {
|
} else {
|
||||||
log.Debug().Str("schemHash", schemHashDB).Msg("Verified Schema consistency (requests db)")
|
log.Info().Msgf("Migrate database [%s] %d -> %d", db.name, currschema, currschema+1)
|
||||||
|
|
||||||
|
err = db.migrateSingle(tctx, tx, currschema, currschema+1)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
currschema = currschema + 1
|
||||||
|
|
||||||
|
ppReInit = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if currschema != schema.RequestsSchemaVersion {
|
if currschema != db.schemaVersion {
|
||||||
return errors.New(fmt.Sprintf("Unknown DB schema: %d", currschema))
|
return errors.New(fmt.Sprintf("Unknown DB schema: %d", currschema))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,10 +176,126 @@ func (db *Database) Migrate(outerctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//goland:noinspection SqlConstantCondition,SqlWithoutWhere
|
||||||
|
func (db *Database) migrateSingle(tctx *simplectx.SimpleContext, tx sq.Tx, schemaFrom int, schemaTo int) error {
|
||||||
|
|
||||||
|
if schemaFrom == schemaTo-1 {
|
||||||
|
|
||||||
|
migSQL := db.schema[schemaTo].MigScript
|
||||||
|
if migSQL == "" {
|
||||||
|
return exerr.New(exerr.TypeInternal, fmt.Sprintf("missing %s migration from %d to %d", db.name, schemaFrom, schemaTo)).Build()
|
||||||
|
}
|
||||||
|
|
||||||
|
return db.migrateBySQL(tctx, tx, migSQL, schemaFrom, schemaTo, db.schema[schemaTo].Hash, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
return exerr.New(exerr.TypeInternal, fmt.Sprintf("missing %s migration from %d to %d", db.name, schemaFrom, schemaTo)).Build()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *Database) migrateBySQL(tctx *simplectx.SimpleContext, tx sq.Tx, stmts string, currSchemaVers int, resultSchemVers int, resultHash string, post func(tctx *simplectx.SimpleContext, tx sq.Tx) error) error {
|
||||||
|
|
||||||
|
schemaHashMeta, err := db.ReadMetaString(tctx, "schema_hash")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
schemHashDBBefore, err := sq.HashSqliteDatabase(tctx, tx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if schemHashDBBefore != langext.Coalesce(schemaHashMeta, "") || langext.Coalesce(schemaHashMeta, "") != db.schema[currSchemaVers].Hash {
|
||||||
|
log.Debug().Str("schemHashDB", schemHashDBBefore).Msg("Schema (primary db)")
|
||||||
|
log.Debug().Str("schemaHashMeta", langext.Coalesce(schemaHashMeta, "")).Msg("Schema (primary db)")
|
||||||
|
log.Debug().Str("schemaHashAsset", db.schema[currSchemaVers].Hash).Msg("Schema (primary db)")
|
||||||
|
return errors.New("database schema does not match (primary db)")
|
||||||
|
} else {
|
||||||
|
log.Debug().Str("schemHash", schemHashDBBefore).Msg("Verified Schema consistency (primary db)")
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info().Msgf("Upgrade schema from %d -> %d", currSchemaVers, resultSchemVers)
|
||||||
|
|
||||||
|
_, err = tx.Exec(tctx, stmts, sq.PP{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
schemHashDBAfter, err := sq.HashSqliteDatabase(tctx, tx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if schemHashDBAfter != resultHash {
|
||||||
|
|
||||||
|
schemaDBStr := langext.Must(createSqliteDatabaseSchemaStringFromSQL(tctx, db.schema[resultSchemVers].SQL))
|
||||||
|
resultDBStr := langext.Must(sq.CreateSqliteDatabaseSchemaString(tctx, tx))
|
||||||
|
|
||||||
|
fmt.Printf("========================================= SQL SCHEMA-DUMP STR (CORRECT | FROM COMPILED SCHEMA):%s\n=========================================\n\n", schemaDBStr)
|
||||||
|
fmt.Printf("========================================= SQL SCHEMA-DUMP STR (CURRNET | AFTER MIGRATION):%s\n=========================================\n\n", resultDBStr)
|
||||||
|
|
||||||
|
return fmt.Errorf("database [%s] schema does not match after [%d -> %d] migration (expected: %s | actual: %s)", db.name, currSchemaVers, resultSchemVers, resultHash, schemHashDBBefore)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.WriteMetaInt(tctx, "schema", int64(resultSchemVers))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.WriteMetaString(tctx, "schema_hash", resultHash)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info().Msgf("Upgrade schema from %d -> %d succesfully", currSchemaVers, resultSchemVers)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func createSqliteDatabaseSchemaStringFromSQL(ctx context.Context, schemaStr string) (string, error) {
|
||||||
|
dbdir := os.TempDir()
|
||||||
|
dbfile1 := filepath.Join(dbdir, langext.MustHexUUID()+".sqlite3")
|
||||||
|
defer func() { _ = os.Remove(dbfile1) }()
|
||||||
|
|
||||||
|
err := os.MkdirAll(dbdir, os.ModePerm)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
url := fmt.Sprintf("file:%s?_pragma=journal_mode(%s)&_pragma=timeout(%d)&_pragma=foreign_keys(%s)&_pragma=busy_timeout(%d)", dbfile1, "DELETE", 1000, "true", 1000)
|
||||||
|
|
||||||
|
xdb, err := sqlx.Open("sqlite", url)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
db := sq.NewDB(xdb, sq.DBOptions{})
|
||||||
|
|
||||||
|
_, err = db.Exec(ctx, schemaStr, sq.PP{})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return sq.CreateSqliteDatabaseSchemaString(ctx, db)
|
||||||
|
}
|
||||||
|
|
||||||
func (db *Database) Ping(ctx context.Context) error {
|
func (db *Database) Ping(ctx context.Context) error {
|
||||||
return db.db.Ping(ctx)
|
return db.db.Ping(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (db *Database) Version(ctx context.Context) (string, string, error) {
|
||||||
|
type rt struct {
|
||||||
|
Version string `db:"version"`
|
||||||
|
SourceID string `db:"sourceID"`
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := sq.QuerySingle[rt](ctx, db.db, "SELECT sqlite_version() AS version, sqlite_source_id() AS sourceID", sq.PP{}, sq.SModeFast, sq.Safe)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp.Version, resp.SourceID, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (db *Database) BeginTx(ctx context.Context) (sq.Tx, error) {
|
func (db *Database) BeginTx(ctx context.Context) (sq.Tx, error) {
|
||||||
return db.db.BeginTransaction(ctx, sql.LevelDefault)
|
return db.db.BeginTransaction(ctx, sql.LevelDefault)
|
||||||
}
|
}
|
||||||
|
@@ -1,60 +1,60 @@
|
|||||||
package schema
|
package schema
|
||||||
|
|
||||||
import _ "embed"
|
import (
|
||||||
|
"embed"
|
||||||
|
_ "embed"
|
||||||
|
)
|
||||||
|
|
||||||
type Def struct {
|
type Def struct {
|
||||||
SQL string
|
SQL string
|
||||||
Hash string
|
Hash string
|
||||||
|
MigScript string
|
||||||
}
|
}
|
||||||
|
|
||||||
//go:embed primary_1.ddl
|
//go:embed *.ddl
|
||||||
var primarySchema1 string
|
//go:embed *.sql
|
||||||
|
var assets embed.FS
|
||||||
//go:embed primary_2.ddl
|
|
||||||
var primarySchema2 string
|
|
||||||
|
|
||||||
//go:embed primary_3.ddl
|
|
||||||
var primarySchema3 string
|
|
||||||
|
|
||||||
//go:embed primary_4.ddl
|
|
||||||
var primarySchema4 string
|
|
||||||
|
|
||||||
//go:embed primary_5.ddl
|
|
||||||
var primarySchema5 string
|
|
||||||
|
|
||||||
//go:embed primary_migration_3_4.ddl
|
|
||||||
var PrimaryMigration_3_4 string
|
|
||||||
|
|
||||||
//go:embed primary_migration_4_5.ddl
|
|
||||||
var PrimaryMigration_4_5 string
|
|
||||||
|
|
||||||
//go:embed requests_1.ddl
|
|
||||||
var requestsSchema1 string
|
|
||||||
|
|
||||||
//go:embed logs_1.ddl
|
|
||||||
var logsSchema1 string
|
|
||||||
|
|
||||||
var PrimarySchema = map[int]Def{
|
var PrimarySchema = map[int]Def{
|
||||||
0: {"", ""},
|
0: {"", "", ""},
|
||||||
1: {primarySchema1, "f2b2847f32681a7178e405553beea4a324034915a0c5a5dc70b3c6abbcc852f2"},
|
1: {readDDL("primary_1.ddl"), "f2b2847f32681a7178e405553beea4a324034915a0c5a5dc70b3c6abbcc852f2", ""},
|
||||||
2: {primarySchema2, "07ed1449114416ed043084a30e0722a5f97bf172161338d2f7106a8dfd387d0a"},
|
2: {readDDL("primary_2.ddl"), "07ed1449114416ed043084a30e0722a5f97bf172161338d2f7106a8dfd387d0a", ""},
|
||||||
3: {primarySchema3, "65c2125ad0e12d02490cf2275f0067ef3c62a8522edf9a35ee8aa3f3c09b12e8"},
|
3: {readDDL("primary_3.ddl"), "65c2125ad0e12d02490cf2275f0067ef3c62a8522edf9a35ee8aa3f3c09b12e8", ""},
|
||||||
4: {primarySchema4, "cb022156ab0e7aea39dd0c985428c43cae7d60e41ca8e9e5a84c774b3019d2ca"},
|
4: {readDDL("primary_4.ddl"), "cb022156ab0e7aea39dd0c985428c43cae7d60e41ca8e9e5a84c774b3019d2ca", readMig("primary_migration_3_4.sql")},
|
||||||
5: {primarySchema5, "9d6217ba4a3503cfe090f72569367f95a413bb14e9effe49ffeabbf255bce8dd"},
|
5: {readDDL("primary_5.ddl"), "9d6217ba4a3503cfe090f72569367f95a413bb14e9effe49ffeabbf255bce8dd", readMig("primary_migration_4_5.sql")},
|
||||||
|
6: {readDDL("primary_6.ddl"), "8e83d20bcd008082713f248ae8cd558335a37a37ce90bd8c86e782da640ee160", readMig("primary_migration_5_6.sql")},
|
||||||
|
7: {readDDL("primary_7.ddl"), "90d8dbc460afe025f9b74cda5c16bb8e58b178df275223bd2531907a8d8c36c3", readMig("primary_migration_6_7.sql")},
|
||||||
|
8: {readDDL("primary_8.ddl"), "746f6005c7a573b8816e5993ecd1d949fe2552b0134ba63bab8b4d5b2b5058ad", readMig("primary_migration_7_8.sql")},
|
||||||
}
|
}
|
||||||
|
|
||||||
var PrimarySchemaVersion = 5
|
var PrimarySchemaVersion = len(PrimarySchema) - 1
|
||||||
|
|
||||||
var RequestsSchema = map[int]Def{
|
var RequestsSchema = map[int]Def{
|
||||||
0: {"", ""},
|
0: {"", "", ""},
|
||||||
1: {requestsSchema1, "ebb0a5748b605e8215437413b738279670190ca8159b6227cfc2aa13418b41e9"},
|
1: {readDDL("requests_1.ddl"), "ebb0a5748b605e8215437413b738279670190ca8159b6227cfc2aa13418b41e9", ""},
|
||||||
}
|
}
|
||||||
|
|
||||||
var RequestsSchemaVersion = 1
|
var RequestsSchemaVersion = len(RequestsSchema) - 1
|
||||||
|
|
||||||
var LogsSchema = map[int]Def{
|
var LogsSchema = map[int]Def{
|
||||||
0: {"", ""},
|
0: {"", "", ""},
|
||||||
1: {logsSchema1, "65fba477c04095effc3a8e1bb79fe7547b8e52e983f776f156266eddc4f201d7"},
|
1: {readDDL("logs_1.ddl"), "65fba477c04095effc3a8e1bb79fe7547b8e52e983f776f156266eddc4f201d7", ""},
|
||||||
}
|
}
|
||||||
|
|
||||||
var LogsSchemaVersion = 1
|
var LogsSchemaVersion = len(LogsSchema) - 1
|
||||||
|
|
||||||
|
func readDDL(name string) string {
|
||||||
|
data, err := assets.ReadFile(name)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readMig(name string) string {
|
||||||
|
data, err := assets.ReadFile(name)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
234
scnserver/db/schema/primary_6.ddl
Normal file
234
scnserver/db/schema/primary_6.ddl
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
CREATE TABLE users
|
||||||
|
(
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
|
||||||
|
username TEXT NULL DEFAULT NULL,
|
||||||
|
|
||||||
|
timestamp_created INTEGER NOT NULL,
|
||||||
|
timestamp_lastread INTEGER NULL DEFAULT NULL,
|
||||||
|
timestamp_lastsent INTEGER NULL DEFAULT NULL,
|
||||||
|
|
||||||
|
messages_sent INTEGER NOT NULL DEFAULT '0',
|
||||||
|
|
||||||
|
quota_used INTEGER NOT NULL DEFAULT '0',
|
||||||
|
quota_used_day TEXT NULL DEFAULT NULL,
|
||||||
|
|
||||||
|
is_pro INTEGER CHECK(is_pro IN (0, 1)) NOT NULL DEFAULT 0,
|
||||||
|
pro_token TEXT NULL DEFAULT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY (user_id)
|
||||||
|
) STRICT;
|
||||||
|
CREATE UNIQUE INDEX "idx_users_protoken" ON users (pro_token) WHERE pro_token IS NOT NULL;
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE keytokens
|
||||||
|
(
|
||||||
|
keytoken_id TEXT NOT NULL,
|
||||||
|
|
||||||
|
timestamp_created INTEGER NOT NULL,
|
||||||
|
timestamp_lastused INTEGER NULL DEFAULT NULL,
|
||||||
|
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
|
||||||
|
owner_user_id TEXT NOT NULL,
|
||||||
|
|
||||||
|
all_channels INTEGER CHECK(all_channels IN (0, 1)) NOT NULL,
|
||||||
|
channels TEXT NOT NULL,
|
||||||
|
token TEXT NOT NULL,
|
||||||
|
permissions TEXT NOT NULL,
|
||||||
|
|
||||||
|
messages_sent INTEGER NOT NULL DEFAULT '0',
|
||||||
|
|
||||||
|
PRIMARY KEY (keytoken_id)
|
||||||
|
) STRICT;
|
||||||
|
CREATE UNIQUE INDEX "idx_keytokens_token" ON keytokens (token);
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE clients
|
||||||
|
(
|
||||||
|
client_id TEXT NOT NULL,
|
||||||
|
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
type TEXT CHECK(type IN ('ANDROID','IOS','LINUX','MACOS','WINDOWS')) NOT NULL,
|
||||||
|
fcm_token TEXT NOT NULL,
|
||||||
|
name TEXT NULL,
|
||||||
|
|
||||||
|
timestamp_created INTEGER NOT NULL,
|
||||||
|
|
||||||
|
agent_model TEXT NOT NULL,
|
||||||
|
agent_version TEXT NOT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY (client_id)
|
||||||
|
) STRICT;
|
||||||
|
CREATE INDEX "idx_clients_userid" ON clients (user_id);
|
||||||
|
CREATE UNIQUE INDEX "idx_clients_fcmtoken" ON clients (fcm_token);
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE channels
|
||||||
|
(
|
||||||
|
channel_id TEXT NOT NULL,
|
||||||
|
|
||||||
|
owner_user_id TEXT NOT NULL,
|
||||||
|
|
||||||
|
internal_name TEXT NOT NULL,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
description_name TEXT NULL,
|
||||||
|
|
||||||
|
subscribe_key TEXT NOT NULL,
|
||||||
|
|
||||||
|
timestamp_created INTEGER NOT NULL,
|
||||||
|
timestamp_lastsent INTEGER NULL DEFAULT NULL,
|
||||||
|
|
||||||
|
messages_sent INTEGER NOT NULL DEFAULT '0',
|
||||||
|
|
||||||
|
PRIMARY KEY (channel_id)
|
||||||
|
) STRICT;
|
||||||
|
CREATE UNIQUE INDEX "idx_channels_identity" ON channels (owner_user_id, internal_name);
|
||||||
|
|
||||||
|
CREATE TABLE subscriptions
|
||||||
|
(
|
||||||
|
subscription_id TEXT NOT NULL,
|
||||||
|
|
||||||
|
subscriber_user_id TEXT NOT NULL,
|
||||||
|
channel_owner_user_id TEXT NOT NULL,
|
||||||
|
channel_internal_name TEXT NOT NULL,
|
||||||
|
channel_id TEXT NOT NULL,
|
||||||
|
|
||||||
|
timestamp_created INTEGER NOT NULL,
|
||||||
|
|
||||||
|
confirmed INTEGER CHECK(confirmed IN (0, 1)) NOT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY (subscription_id)
|
||||||
|
) STRICT;
|
||||||
|
CREATE UNIQUE INDEX "idx_subscriptions_ref" ON subscriptions (subscriber_user_id, channel_owner_user_id, channel_internal_name);
|
||||||
|
CREATE INDEX "idx_subscriptions_chan" ON subscriptions (channel_id);
|
||||||
|
CREATE INDEX "idx_subscriptions_subuser" ON subscriptions (subscriber_user_id);
|
||||||
|
CREATE INDEX "idx_subscriptions_ownuser" ON subscriptions (channel_owner_user_id);
|
||||||
|
CREATE INDEX "idx_subscriptions_tsc" ON subscriptions (timestamp_created);
|
||||||
|
CREATE INDEX "idx_subscriptions_conf" ON subscriptions (confirmed);
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE messages
|
||||||
|
(
|
||||||
|
message_id TEXT NOT NULL,
|
||||||
|
sender_user_id TEXT NOT NULL,
|
||||||
|
channel_internal_name TEXT NOT NULL,
|
||||||
|
channel_id TEXT NOT NULL,
|
||||||
|
sender_ip TEXT NOT NULL,
|
||||||
|
sender_name TEXT NULL,
|
||||||
|
|
||||||
|
timestamp_real INTEGER NOT NULL,
|
||||||
|
timestamp_client INTEGER NULL,
|
||||||
|
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
content TEXT NULL,
|
||||||
|
priority INTEGER CHECK(priority IN (0, 1, 2)) NOT NULL,
|
||||||
|
usr_message_id TEXT NULL,
|
||||||
|
|
||||||
|
used_key_id TEXT NOT NULL,
|
||||||
|
|
||||||
|
deleted INTEGER CHECK(deleted IN (0, 1)) NOT NULL DEFAULT '0',
|
||||||
|
|
||||||
|
PRIMARY KEY (message_id)
|
||||||
|
) STRICT;
|
||||||
|
CREATE INDEX "idx_messages_channel" ON messages (channel_internal_name COLLATE BINARY);
|
||||||
|
CREATE INDEX "idx_messages_channel_nc" ON messages (channel_internal_name COLLATE NOCASE);
|
||||||
|
CREATE UNIQUE INDEX "idx_messages_idempotency" ON messages (sender_user_id, usr_message_id COLLATE BINARY);
|
||||||
|
CREATE INDEX "idx_messages_senderip" ON messages (sender_ip COLLATE BINARY);
|
||||||
|
CREATE INDEX "idx_messages_sendername" ON messages (sender_name COLLATE BINARY);
|
||||||
|
CREATE INDEX "idx_messages_sendername_nc" ON messages (sender_name COLLATE NOCASE);
|
||||||
|
CREATE INDEX "idx_messages_title" ON messages (title COLLATE BINARY);
|
||||||
|
CREATE INDEX "idx_messages_title_nc" ON messages (title COLLATE NOCASE);
|
||||||
|
CREATE INDEX "idx_messages_usedkey" ON messages (sender_user_id, used_key_id);
|
||||||
|
CREATE INDEX "idx_messages_deleted" ON messages (deleted);
|
||||||
|
|
||||||
|
|
||||||
|
CREATE VIRTUAL TABLE messages_fts USING fts5
|
||||||
|
(
|
||||||
|
channel_internal_name,
|
||||||
|
sender_name,
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
|
||||||
|
tokenize = unicode61,
|
||||||
|
content = 'messages',
|
||||||
|
content_rowid = 'rowid'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TRIGGER fts_insert AFTER INSERT ON messages BEGIN
|
||||||
|
INSERT INTO messages_fts (rowid, channel_internal_name, sender_name, title, content) VALUES (new.rowid, new.channel_internal_name, new.sender_name, new.title, new.content);
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER fts_update AFTER UPDATE ON messages BEGIN
|
||||||
|
INSERT INTO messages_fts (messages_fts, rowid, channel_internal_name, sender_name, title, content) VALUES ('delete', old.rowid, old.channel_internal_name, old.sender_name, old.title, old.content);
|
||||||
|
INSERT INTO messages_fts ( rowid, channel_internal_name, sender_name, title, content) VALUES ( new.rowid, new.channel_internal_name, new.sender_name, new.title, new.content);
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER fts_delete AFTER DELETE ON messages BEGIN
|
||||||
|
INSERT INTO messages_fts (messages_fts, rowid, channel_internal_name, sender_name, title, content) VALUES ('delete', old.rowid, old.channel_internal_name, old.sender_name, old.title, old.content);
|
||||||
|
END;
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE deliveries
|
||||||
|
(
|
||||||
|
delivery_id TEXT NOT NULL,
|
||||||
|
|
||||||
|
message_id TEXT NOT NULL,
|
||||||
|
receiver_user_id TEXT NOT NULL,
|
||||||
|
receiver_client_id TEXT NOT NULL,
|
||||||
|
|
||||||
|
timestamp_created INTEGER NOT NULL,
|
||||||
|
timestamp_finalized INTEGER NULL,
|
||||||
|
|
||||||
|
|
||||||
|
status TEXT CHECK(status IN ('RETRY','SUCCESS','FAILED')) NOT NULL,
|
||||||
|
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
next_delivery INTEGER NULL DEFAULT NULL,
|
||||||
|
|
||||||
|
fcm_message_id TEXT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY (delivery_id)
|
||||||
|
) STRICT;
|
||||||
|
CREATE INDEX "idx_deliveries_receiver" ON deliveries (message_id, receiver_client_id);
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE compat_ids
|
||||||
|
(
|
||||||
|
old INTEGER NOT NULL,
|
||||||
|
new TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL
|
||||||
|
) STRICT;
|
||||||
|
CREATE UNIQUE INDEX "idx_compatids_new" ON compat_ids (new);
|
||||||
|
CREATE UNIQUE INDEX "idx_compatids_old" ON compat_ids (old, type);
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE compat_acks
|
||||||
|
(
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
message_id TEXT NOT NULL
|
||||||
|
) STRICT;
|
||||||
|
CREATE INDEX "idx_compatacks_userid" ON compat_acks (user_id);
|
||||||
|
CREATE UNIQUE INDEX "idx_compatacks_messageid" ON compat_acks (message_id);
|
||||||
|
CREATE UNIQUE INDEX "idx_compatacks_userid_messageid" ON compat_acks (user_id, message_id);
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE compat_clients
|
||||||
|
(
|
||||||
|
client_id TEXT NOT NULL
|
||||||
|
) STRICT;
|
||||||
|
CREATE UNIQUE INDEX "idx_compatclient_clientid" ON compat_clients (client_id);
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE `meta`
|
||||||
|
(
|
||||||
|
meta_key TEXT NOT NULL,
|
||||||
|
value_int INTEGER NULL,
|
||||||
|
value_txt TEXT NULL,
|
||||||
|
value_real REAL NULL,
|
||||||
|
value_blob BLOB NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY (meta_key)
|
||||||
|
) STRICT;
|
||||||
|
|
||||||
|
|
||||||
|
INSERT INTO meta (meta_key, value_int) VALUES ('schema', 3)
|
238
scnserver/db/schema/primary_7.ddl
Normal file
238
scnserver/db/schema/primary_7.ddl
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
CREATE TABLE users
|
||||||
|
(
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
|
||||||
|
username TEXT NULL DEFAULT NULL,
|
||||||
|
|
||||||
|
timestamp_created INTEGER NOT NULL,
|
||||||
|
timestamp_lastread INTEGER NULL DEFAULT NULL,
|
||||||
|
timestamp_lastsent INTEGER NULL DEFAULT NULL,
|
||||||
|
|
||||||
|
messages_sent INTEGER NOT NULL DEFAULT '0',
|
||||||
|
|
||||||
|
quota_used INTEGER NOT NULL DEFAULT '0',
|
||||||
|
quota_used_day TEXT NULL DEFAULT NULL,
|
||||||
|
|
||||||
|
is_pro INTEGER CHECK(is_pro IN (0, 1)) NOT NULL DEFAULT 0,
|
||||||
|
pro_token TEXT NULL DEFAULT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY (user_id)
|
||||||
|
) STRICT;
|
||||||
|
CREATE UNIQUE INDEX "idx_users_protoken" ON users (pro_token) WHERE pro_token IS NOT NULL;
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE keytokens
|
||||||
|
(
|
||||||
|
keytoken_id TEXT NOT NULL,
|
||||||
|
|
||||||
|
timestamp_created INTEGER NOT NULL,
|
||||||
|
timestamp_lastused INTEGER NULL DEFAULT NULL,
|
||||||
|
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
|
||||||
|
owner_user_id TEXT NOT NULL,
|
||||||
|
|
||||||
|
all_channels INTEGER CHECK(all_channels IN (0, 1)) NOT NULL,
|
||||||
|
channels TEXT NOT NULL,
|
||||||
|
token TEXT NOT NULL,
|
||||||
|
permissions TEXT NOT NULL,
|
||||||
|
|
||||||
|
messages_sent INTEGER NOT NULL DEFAULT '0',
|
||||||
|
|
||||||
|
PRIMARY KEY (keytoken_id)
|
||||||
|
) STRICT;
|
||||||
|
CREATE UNIQUE INDEX "idx_keytokens_token" ON keytokens (token);
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE clients
|
||||||
|
(
|
||||||
|
client_id TEXT NOT NULL,
|
||||||
|
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
type TEXT CHECK(type IN ('ANDROID','IOS','LINUX','MACOS','WINDOWS')) NOT NULL,
|
||||||
|
fcm_token TEXT NOT NULL,
|
||||||
|
name TEXT NULL,
|
||||||
|
|
||||||
|
timestamp_created INTEGER NOT NULL,
|
||||||
|
|
||||||
|
agent_model TEXT NOT NULL,
|
||||||
|
agent_version TEXT NOT NULL,
|
||||||
|
|
||||||
|
|
||||||
|
deleted INTEGER CHECK(deleted IN (0, 1)) NOT NULL DEFAULT '0',
|
||||||
|
|
||||||
|
PRIMARY KEY (client_id)
|
||||||
|
) STRICT;
|
||||||
|
CREATE INDEX "idx_clients_userid" ON clients (user_id);
|
||||||
|
CREATE INDEX "idx_clients_deleted" ON clients (deleted);
|
||||||
|
CREATE UNIQUE INDEX "idx_clients_fcmtoken" ON clients (fcm_token);
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE channels
|
||||||
|
(
|
||||||
|
channel_id TEXT NOT NULL,
|
||||||
|
|
||||||
|
owner_user_id TEXT NOT NULL,
|
||||||
|
|
||||||
|
internal_name TEXT NOT NULL,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
description_name TEXT NULL,
|
||||||
|
|
||||||
|
subscribe_key TEXT NOT NULL,
|
||||||
|
|
||||||
|
timestamp_created INTEGER NOT NULL,
|
||||||
|
timestamp_lastsent INTEGER NULL DEFAULT NULL,
|
||||||
|
|
||||||
|
messages_sent INTEGER NOT NULL DEFAULT '0',
|
||||||
|
|
||||||
|
PRIMARY KEY (channel_id)
|
||||||
|
) STRICT;
|
||||||
|
CREATE UNIQUE INDEX "idx_channels_identity" ON channels (owner_user_id, internal_name);
|
||||||
|
|
||||||
|
CREATE TABLE subscriptions
|
||||||
|
(
|
||||||
|
subscription_id TEXT NOT NULL,
|
||||||
|
|
||||||
|
subscriber_user_id TEXT NOT NULL,
|
||||||
|
channel_owner_user_id TEXT NOT NULL,
|
||||||
|
channel_internal_name TEXT NOT NULL,
|
||||||
|
channel_id TEXT NOT NULL,
|
||||||
|
|
||||||
|
timestamp_created INTEGER NOT NULL,
|
||||||
|
|
||||||
|
confirmed INTEGER CHECK(confirmed IN (0, 1)) NOT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY (subscription_id)
|
||||||
|
) STRICT;
|
||||||
|
CREATE UNIQUE INDEX "idx_subscriptions_ref" ON subscriptions (subscriber_user_id, channel_owner_user_id, channel_internal_name);
|
||||||
|
CREATE INDEX "idx_subscriptions_chan" ON subscriptions (channel_id);
|
||||||
|
CREATE INDEX "idx_subscriptions_subuser" ON subscriptions (subscriber_user_id);
|
||||||
|
CREATE INDEX "idx_subscriptions_ownuser" ON subscriptions (channel_owner_user_id);
|
||||||
|
CREATE INDEX "idx_subscriptions_tsc" ON subscriptions (timestamp_created);
|
||||||
|
CREATE INDEX "idx_subscriptions_conf" ON subscriptions (confirmed);
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE messages
|
||||||
|
(
|
||||||
|
message_id TEXT NOT NULL,
|
||||||
|
sender_user_id TEXT NOT NULL,
|
||||||
|
channel_internal_name TEXT NOT NULL,
|
||||||
|
channel_id TEXT NOT NULL,
|
||||||
|
sender_ip TEXT NOT NULL,
|
||||||
|
sender_name TEXT NULL,
|
||||||
|
|
||||||
|
timestamp_real INTEGER NOT NULL,
|
||||||
|
timestamp_client INTEGER NULL,
|
||||||
|
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
content TEXT NULL,
|
||||||
|
priority INTEGER CHECK(priority IN (0, 1, 2)) NOT NULL,
|
||||||
|
usr_message_id TEXT NULL,
|
||||||
|
|
||||||
|
used_key_id TEXT NOT NULL,
|
||||||
|
|
||||||
|
deleted INTEGER CHECK(deleted IN (0, 1)) NOT NULL DEFAULT '0',
|
||||||
|
|
||||||
|
PRIMARY KEY (message_id)
|
||||||
|
) STRICT;
|
||||||
|
CREATE INDEX "idx_messages_channel" ON messages (channel_internal_name COLLATE BINARY);
|
||||||
|
CREATE INDEX "idx_messages_channel_nc" ON messages (channel_internal_name COLLATE NOCASE);
|
||||||
|
CREATE UNIQUE INDEX "idx_messages_idempotency" ON messages (sender_user_id, usr_message_id COLLATE BINARY);
|
||||||
|
CREATE INDEX "idx_messages_senderip" ON messages (sender_ip COLLATE BINARY);
|
||||||
|
CREATE INDEX "idx_messages_sendername" ON messages (sender_name COLLATE BINARY);
|
||||||
|
CREATE INDEX "idx_messages_sendername_nc" ON messages (sender_name COLLATE NOCASE);
|
||||||
|
CREATE INDEX "idx_messages_title" ON messages (title COLLATE BINARY);
|
||||||
|
CREATE INDEX "idx_messages_title_nc" ON messages (title COLLATE NOCASE);
|
||||||
|
CREATE INDEX "idx_messages_usedkey" ON messages (sender_user_id, used_key_id);
|
||||||
|
CREATE INDEX "idx_messages_deleted" ON messages (deleted);
|
||||||
|
|
||||||
|
|
||||||
|
CREATE VIRTUAL TABLE messages_fts USING fts5
|
||||||
|
(
|
||||||
|
channel_internal_name,
|
||||||
|
sender_name,
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
|
||||||
|
tokenize = unicode61,
|
||||||
|
content = 'messages',
|
||||||
|
content_rowid = 'rowid'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TRIGGER fts_insert AFTER INSERT ON messages BEGIN
|
||||||
|
INSERT INTO messages_fts (rowid, channel_internal_name, sender_name, title, content) VALUES (new.rowid, new.channel_internal_name, new.sender_name, new.title, new.content);
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER fts_update AFTER UPDATE ON messages BEGIN
|
||||||
|
INSERT INTO messages_fts (messages_fts, rowid, channel_internal_name, sender_name, title, content) VALUES ('delete', old.rowid, old.channel_internal_name, old.sender_name, old.title, old.content);
|
||||||
|
INSERT INTO messages_fts ( rowid, channel_internal_name, sender_name, title, content) VALUES ( new.rowid, new.channel_internal_name, new.sender_name, new.title, new.content);
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER fts_delete AFTER DELETE ON messages BEGIN
|
||||||
|
INSERT INTO messages_fts (messages_fts, rowid, channel_internal_name, sender_name, title, content) VALUES ('delete', old.rowid, old.channel_internal_name, old.sender_name, old.title, old.content);
|
||||||
|
END;
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE deliveries
|
||||||
|
(
|
||||||
|
delivery_id TEXT NOT NULL,
|
||||||
|
|
||||||
|
message_id TEXT NOT NULL,
|
||||||
|
receiver_user_id TEXT NOT NULL,
|
||||||
|
receiver_client_id TEXT NOT NULL,
|
||||||
|
|
||||||
|
timestamp_created INTEGER NOT NULL,
|
||||||
|
timestamp_finalized INTEGER NULL,
|
||||||
|
|
||||||
|
|
||||||
|
status TEXT CHECK(status IN ('RETRY','SUCCESS','FAILED')) NOT NULL,
|
||||||
|
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
next_delivery INTEGER NULL DEFAULT NULL,
|
||||||
|
|
||||||
|
fcm_message_id TEXT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY (delivery_id)
|
||||||
|
) STRICT;
|
||||||
|
CREATE INDEX "idx_deliveries_receiver" ON deliveries (message_id, receiver_client_id);
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE compat_ids
|
||||||
|
(
|
||||||
|
old INTEGER NOT NULL,
|
||||||
|
new TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL
|
||||||
|
) STRICT;
|
||||||
|
CREATE UNIQUE INDEX "idx_compatids_new" ON compat_ids (new);
|
||||||
|
CREATE UNIQUE INDEX "idx_compatids_old" ON compat_ids (old, type);
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE compat_acks
|
||||||
|
(
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
message_id TEXT NOT NULL
|
||||||
|
) STRICT;
|
||||||
|
CREATE INDEX "idx_compatacks_userid" ON compat_acks (user_id);
|
||||||
|
CREATE UNIQUE INDEX "idx_compatacks_messageid" ON compat_acks (message_id);
|
||||||
|
CREATE UNIQUE INDEX "idx_compatacks_userid_messageid" ON compat_acks (user_id, message_id);
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE compat_clients
|
||||||
|
(
|
||||||
|
client_id TEXT NOT NULL
|
||||||
|
) STRICT;
|
||||||
|
CREATE UNIQUE INDEX "idx_compatclient_clientid" ON compat_clients (client_id);
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE `meta`
|
||||||
|
(
|
||||||
|
meta_key TEXT NOT NULL,
|
||||||
|
value_int INTEGER NULL,
|
||||||
|
value_txt TEXT NULL,
|
||||||
|
value_real REAL NULL,
|
||||||
|
value_blob BLOB NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY (meta_key)
|
||||||
|
) STRICT;
|
||||||
|
|
||||||
|
|
||||||
|
INSERT INTO meta (meta_key, value_int) VALUES ('schema', 3)
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user