Merge branch 'flutter_app'
All checks were successful
Build Docker and Deploy / Build Docker Container (push) Successful in 2m37s
Build Docker and Deploy / Deploy to Server (push) Successful in 12s

This commit is contained in:
Mike Schwörer 2024-06-15 18:24:49 +02:00
commit b68f3bdb23
Signed by: Mikescher
GPG Key ID: D3C7172E0A70F8CF
77 changed files with 1684 additions and 429 deletions

View File

@ -1,7 +1,7 @@
run: run:
dart run build_runner build flutter pub run build_runner build
flutter run flutter run
test: test:
@ -11,8 +11,11 @@ fix:
dart fix --apply dart fix --apply
gen: gen:
dart run build_runner build flutter pub run build_runner build
autoreload: autoreload:
@# run `make run` in another terminal (or another variant of flutter run) @# run `make run` in another terminal (or another variant of flutter run)
@_utils/autoreload.sh @_utils/autoreload.sh
icons:
flutter pub run flutter_launcher_icons -f "flutter_launcher_icons.yaml"

View File

@ -9,7 +9,7 @@
<application <application
android:label="simplecloudnotifier" android:label="simplecloudnotifier"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/launcher_icon">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@ -0,0 +1,19 @@
flutter_launcher_icons:
android: "launcher_icon"
ios: true
remove_alpha_ios: true
image_path: "../data/icon_512_nobox.png"
min_sdk_android: 21 # android min sdk min:16, default 21
adaptive_icon_monochrome: "../data/icon_512_transparent.png"
web:
generate: true
image_path: "../data/icon_512_nobox.png"
background_color: "#hexcode"
theme_color: "#hexcode"
windows:
generate: true
image_path: "../data/icon_512_nobox.png"
icon_size: 48 # min:48, max:256, default: 48
macos:
generate: true
image_path: "../data/icon_512_nobox.png"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 926 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

@ -127,6 +127,16 @@ class APIClient {
); );
} }
static Future<UserPreview> getUserPreview(TokenSource auth, String uid) async {
return await _request(
name: 'getUserPreview',
method: 'GET',
relURL: 'preview/users/$uid',
fn: UserPreview.fromJson,
authToken: auth.getToken(),
);
}
static Future<Client> addClient(TokenSource auth, String fcmToken, String agentModel, String agentVersion, String? name, String clientType) async { static Future<Client> addClient(TokenSource auth, String fcmToken, String agentModel, String agentVersion, String? name, String clientType) async {
return await _request( return await _request(
name: 'addClient', name: 'addClient',
@ -191,6 +201,16 @@ class APIClient {
); );
} }
static Future<ChannelPreview> getChannelPreview(TokenSource auth, String cid) async {
return await _request(
name: 'getChannelPreview',
method: 'GET',
relURL: 'preview/channels/${cid}',
fn: ChannelPreview.fromJson,
authToken: auth.getToken(),
);
}
static Future<(String, List<Message>)> getMessageList(TokenSource auth, String pageToken, {int? pageSize, List<String>? channelIDs}) async { static Future<(String, List<Message>)> getMessageList(TokenSource auth, String pageToken, {int? pageSize, List<String>? channelIDs}) async {
return await _request( return await _request(
name: 'getMessageList', name: 'getMessageList',
@ -275,6 +295,16 @@ class APIClient {
); );
} }
static Future<KeyTokenPreview> getKeyTokenPreview(TokenSource auth, String kid) async {
return await _request(
name: 'getKeyTokenPreview',
method: 'GET',
relURL: 'preview/keys/$kid',
fn: KeyTokenPreview.fromJson,
authToken: auth.getToken(),
);
}
static Future<KeyToken> getKeyTokenByToken(String userid, String token) async { static Future<KeyToken> getKeyTokenByToken(String userid, String token) async {
return await _request( return await _request(
name: 'getCurrentKeyToken', name: 'getCurrentKeyToken',

View File

@ -1,8 +1,11 @@
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_progress_indicator.dart';
import 'package:simplecloudnotifier/pages/debug/debug_main.dart'; import 'package:simplecloudnotifier/pages/debug/debug_main.dart';
import 'package:simplecloudnotifier/state/app_theme.dart'; import 'package:simplecloudnotifier/state/app_theme.dart';
import 'package:simplecloudnotifier/utils/navi.dart';
class SCNAppBar extends StatelessWidget implements PreferredSizeWidget { class SCNAppBar extends StatelessWidget implements PreferredSizeWidget {
const SCNAppBar({ const SCNAppBar({
@ -26,6 +29,16 @@ class SCNAppBar extends StatelessWidget implements PreferredSizeWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
var actions = <Widget>[]; var actions = <Widget>[];
if (showDebug) {
actions.add(IconButton(
icon: const Icon(FontAwesomeIcons.solidSpiderBlackWidow),
tooltip: 'Debug',
onPressed: () {
Navi.push(context, () => DebugMainPage());
},
));
}
if (showThemeSwitch) { if (showThemeSwitch) {
actions.add(Consumer<AppTheme>( actions.add(Consumer<AppTheme>(
builder: (context, appTheme, child) => IconButton( builder: (context, appTheme, child) => IconButton(
@ -35,19 +48,16 @@ class SCNAppBar extends StatelessWidget implements PreferredSizeWidget {
), ),
)); ));
} else { } else {
actions.add(SizedBox.square(dimension: 40)); actions.add(Visibility(
} visible: false,
maintainSize: true,
if (showDebug) { maintainAnimation: true,
actions.add(IconButton( maintainState: true,
icon: const Icon(FontAwesomeIcons.solidSpiderBlackWidow), child: IconButton(
tooltip: 'Debug', icon: const Icon(FontAwesomeIcons.square),
onPressed: () { onPressed: () {/*TODO*/},
Navigator.push(context, MaterialPageRoute<DebugMainPage>(builder: (context) => DebugMainPage())); ),
},
)); ));
} else {
actions.add(SizedBox.square(dimension: 40));
} }
if (showSearch) { if (showSearch) {
@ -63,13 +73,26 @@ class SCNAppBar extends StatelessWidget implements PreferredSizeWidget {
onPressed: onShare ?? () {}, onPressed: onShare ?? () {},
)); ));
} else { } else {
actions.add(SizedBox.square(dimension: 40)); actions.add(Visibility(
visible: false,
maintainSize: true,
maintainAnimation: true,
maintainState: true,
child: IconButton(
icon: const Icon(FontAwesomeIcons.square),
onPressed: () {/*TODO*/},
),
));
} }
return AppBar( return AppBar(
title: Text(title ?? 'Simple Cloud Notifier 2.0'), title: Text(title ?? 'Simple Cloud Notifier 2.0'),
actions: actions, actions: actions,
backgroundColor: Theme.of(context).secondaryHeaderColor, backgroundColor: Theme.of(context).secondaryHeaderColor,
bottom: PreferredSize(
preferredSize: Size(double.infinity, 1.0),
child: AppBarProgressIndicator(),
),
); );
} }

View File

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

View File

@ -5,14 +5,19 @@ import 'package:flutter/material.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/client.dart'; import 'package:simplecloudnotifier/models/client.dart';
import 'package:simplecloudnotifier/models/message.dart';
import 'package:simplecloudnotifier/nav_layout.dart'; import 'package:simplecloudnotifier/nav_layout.dart';
import 'package:simplecloudnotifier/state/app_bar_state.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/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/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/utils/navi.dart';
import 'package:toastification/toastification.dart'; import 'package:toastification/toastification.dart';
import 'firebase_options.dart'; import 'firebase_options.dart';
@ -34,6 +39,9 @@ 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(ChannelAdapter());
Hive.registerAdapter(FBMessageAdapter());
print('[INIT] Load Hive<scn-requests>...'); print('[INIT] Load Hive<scn-requests>...');
@ -55,6 +63,36 @@ void main() async {
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);
} }
print('[INIT] Load Hive<scn-message-cache>...');
try {
await Hive.openBox<Message>('scn-message-cache');
} catch (exc, trace) {
Hive.deleteBoxFromDisk('scn-message-cache');
await Hive.openBox<Message>('scn-message-cache');
ApplicationLog.error('Failed to open Hive-Box: scn-message-cache' + exc.toString(), trace: trace);
}
print('[INIT] Load Hive<scn-channel-cache>...');
try {
await Hive.openBox<Channel>('scn-channel-cache');
} catch (exc, trace) {
Hive.deleteBoxFromDisk('scn-channel-cache');
await Hive.openBox<Channel>('scn-channel-cache');
ApplicationLog.error('Failed to open Hive-Box: scn-channel-cache' + exc.toString(), trace: trace);
}
print('[INIT] Load Hive<scn-fb-messages>...');
try {
await Hive.openBox<FBMessage>('scn-fb-messages');
} catch (exc, trace) {
Hive.deleteBoxFromDisk('scn-fb-messages');
await Hive.openBox<FBMessage>('scn-fb-messages');
ApplicationLog.error('Failed to open Hive-Box: scn-fb-messages' + exc.toString(), trace: trace);
}
print('[INIT] Load AppAuth...'); print('[INIT] Load AppAuth...');
final appAuth = AppAuth(); // ensure UserAccount is loaded final appAuth = AppAuth(); // ensure UserAccount is loaded
@ -102,6 +140,9 @@ void main() async {
} catch (exc, trace) { } catch (exc, trace) {
ApplicationLog.error('Failed to get+set firebase token: ' + exc.toString(), trace: trace); ApplicationLog.error('Failed to get+set firebase token: ' + exc.toString(), trace: trace);
} }
FirebaseMessaging.onBackgroundMessage(_onBackgroundMessage);
FirebaseMessaging.onMessage.listen(_onForegroundMessage);
} else { } else {
print('[INIT] Skip Firebase init (Platform == Linux)...'); print('[INIT] Skip Firebase init (Platform == Linux)...');
} }
@ -113,12 +154,40 @@ void main() async {
providers: [ providers: [
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),
], ],
child: const SCNApp(), child: SCNApp(),
), ),
); );
} }
class SCNApp extends StatelessWidget {
SCNApp({super.key});
@override
Widget build(BuildContext context) {
return ToastificationWrapper(
config: ToastificationConfig(
itemWidth: 440,
marginBuilder: (alignment) => EdgeInsets.symmetric(vertical: 64),
animationDuration: Duration(milliseconds: 200),
),
child: Consumer<AppTheme>(
builder: (context, appTheme, child) => MaterialApp(
title: 'SimpleCloudNotifier',
navigatorObservers: [Navi.routeObserver, Navi.modalRouteObserver],
theme: ThemeData(
//TODO color settings
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue, brightness: appTheme.darkMode ? Brightness.dark : Brightness.light),
useMaterial3: true,
),
home: SCNNavLayout(),
),
),
);
}
}
void setFirebaseToken(String fcmToken) async { void setFirebaseToken(String fcmToken) async {
final acc = AppAuth(); final acc = AppAuth();
@ -132,7 +201,7 @@ void setFirebaseToken(String fcmToken) async {
Client? client; Client? client;
try { try {
client = await acc.loadClient(force: true); client = await acc.loadClient(forceIfOlder: Duration(seconds: 60));
} catch (exc, trace) { } catch (exc, trace) {
ApplicationLog.error('Failed to get client: ' + exc.toString(), trace: trace); ApplicationLog.error('Failed to get client: ' + exc.toString(), trace: trace);
return; return;
@ -155,28 +224,18 @@ void setFirebaseToken(String fcmToken) async {
} }
} }
class SCNApp extends StatelessWidget { Future<void> _onBackgroundMessage(RemoteMessage message) async {
const SCNApp({super.key}); await _receiveMessage(message, false);
}
@override void _onForegroundMessage(RemoteMessage message) {
Widget build(BuildContext context) { _receiveMessage(message, true);
return ToastificationWrapper(
config: ToastificationConfig(
itemWidth: 440,
marginBuilder: (alignment) => EdgeInsets.symmetric(vertical: 64),
animationDuration: Duration(milliseconds: 200),
),
child: Consumer<AppTheme>(
builder: (context, appTheme, child) => MaterialApp(
title: 'SimpleCloudNotifier',
theme: ThemeData(
//TODO color settings
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue, brightness: appTheme.darkMode ? Brightness.dark : Brightness.light),
useMaterial3: true,
),
home: SCNNavLayout(),
),
),
);
} }
Future<void> _receiveMessage(RemoteMessage message, bool foreground) async {
// ensure init
Hive.openBox<SCNLog>('scn-logs');
ApplicationLog.info('Received FB message (${foreground ? 'foreground' : 'background'}): ${message.messageId ?? 'NULL'}');
FBMessageLog.insert(message);
} }

View File

@ -1,17 +1,32 @@
import 'package:hive_flutter/hive_flutter.dart';
import 'package:simplecloudnotifier/models/subscription.dart'; import 'package:simplecloudnotifier/models/subscription.dart';
import 'package:simplecloudnotifier/state/interfaces.dart';
class Channel { part 'channel.g.dart';
@HiveType(typeId: 104)
class Channel extends HiveObject implements FieldDebuggable {
@HiveField(0)
final String channelID; final String channelID;
@HiveField(10)
final String ownerUserID; final String ownerUserID;
@HiveField(11)
final String internalName; final String internalName;
@HiveField(12)
final String displayName; final String displayName;
@HiveField(13)
final String? descriptionName; final String? descriptionName;
@HiveField(14)
final String? subscribeKey; final String? subscribeKey;
@HiveField(15)
final String timestampCreated; final String timestampCreated;
@HiveField(16)
final String? timestampLastSent; final String? timestampLastSent;
@HiveField(17)
final int messagesSent; final int messagesSent;
const Channel({ Channel({
required this.channelID, required this.channelID,
required this.ownerUserID, required this.ownerUserID,
required this.internalName, required this.internalName,
@ -36,6 +51,25 @@ class Channel {
messagesSent: json['messages_sent'] as int, messagesSent: json['messages_sent'] as int,
); );
} }
@override
String toString() {
return 'Channel[${this.channelID}]';
}
List<(String, String)> debugFieldList() {
return [
('channelID', this.channelID),
('ownerUserID', this.ownerUserID),
('internalName', this.internalName),
('displayName', this.displayName),
('descriptionName', this.descriptionName ?? ''),
('subscribeKey', this.subscribeKey ?? ''),
('timestampCreated', this.timestampCreated),
('timestampLastSent', this.timestampLastSent ?? ''),
('messagesSent', '${this.messagesSent}'),
];
}
} }
class ChannelWithSubscription { class ChannelWithSubscription {
@ -58,3 +92,29 @@ class ChannelWithSubscription {
return jsonArr.map<ChannelWithSubscription>((e) => ChannelWithSubscription.fromJson(e as Map<String, dynamic>)).toList(); return jsonArr.map<ChannelWithSubscription>((e) => ChannelWithSubscription.fromJson(e as Map<String, dynamic>)).toList();
} }
} }
class ChannelPreview {
final String channelID;
final String ownerUserID;
final String internalName;
final String displayName;
final String? descriptionName;
const ChannelPreview({
required this.channelID,
required this.ownerUserID,
required this.internalName,
required this.displayName,
required this.descriptionName,
});
factory ChannelPreview.fromJson(Map<String, dynamic> json) {
return ChannelPreview(
channelID: json['channel_id'] as String,
ownerUserID: json['owner_user_id'] as String,
internalName: json['internal_name'] as String,
displayName: json['display_name'] as String,
descriptionName: json['description_name'] as String?,
);
}
}

View File

@ -0,0 +1,65 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'channel.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class ChannelAdapter extends TypeAdapter<Channel> {
@override
final int typeId = 104;
@override
Channel read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return Channel(
channelID: fields[0] as String,
ownerUserID: fields[10] as String,
internalName: fields[11] as String,
displayName: fields[12] as String,
descriptionName: fields[13] as String?,
subscribeKey: fields[14] as String?,
timestampCreated: fields[15] as String,
timestampLastSent: fields[16] as String?,
messagesSent: fields[17] as int,
);
}
@override
void write(BinaryWriter writer, Channel obj) {
writer
..writeByte(9)
..writeByte(0)
..write(obj.channelID)
..writeByte(10)
..write(obj.ownerUserID)
..writeByte(11)
..write(obj.internalName)
..writeByte(12)
..write(obj.displayName)
..writeByte(13)
..write(obj.descriptionName)
..writeByte(14)
..write(obj.subscribeKey)
..writeByte(15)
..write(obj.timestampCreated)
..writeByte(16)
..write(obj.timestampLastSent)
..writeByte(17)
..write(obj.messagesSent);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ChannelAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@ -39,3 +39,32 @@ class KeyToken {
return jsonArr.map<KeyToken>((e) => KeyToken.fromJson(e as Map<String, dynamic>)).toList(); return jsonArr.map<KeyToken>((e) => KeyToken.fromJson(e as Map<String, dynamic>)).toList();
} }
} }
class KeyTokenPreview {
final String keytokenID;
final String name;
final String ownerUserID;
final bool allChannels;
final List<String> channels;
final String permissions;
const KeyTokenPreview({
required this.keytokenID,
required this.name,
required this.ownerUserID,
required this.allChannels,
required this.channels,
required this.permissions,
});
factory KeyTokenPreview.fromJson(Map<String, dynamic> json) {
return KeyTokenPreview(
keytokenID: json['keytoken_id'] as String,
name: json['name'] as String,
ownerUserID: json['owner_user_id'] as String,
allChannels: json['all_channels'] as bool,
channels: (json['channels'] as List<dynamic>).map((e) => e as String).toList(),
permissions: json['permissions'] as String,
);
}
}

View File

@ -1,19 +1,39 @@
class Message { import 'package:hive_flutter/hive_flutter.dart';
import 'package:simplecloudnotifier/state/interfaces.dart';
part 'message.g.dart';
@HiveType(typeId: 105)
class Message extends HiveObject implements FieldDebuggable {
@HiveField(0)
final String messageID; final String messageID;
@HiveField(10)
final String senderUserID; final String senderUserID;
@HiveField(11)
final String channelInternalName; final String channelInternalName;
@HiveField(12)
final String channelID; final String channelID;
@HiveField(13)
final String? senderName; final String? senderName;
@HiveField(14)
final String senderIP; final String senderIP;
@HiveField(15)
final String timestamp; final String timestamp;
@HiveField(16)
final String title; final String title;
@HiveField(17)
final String? content; final String? content;
@HiveField(18)
final int priority; final int priority;
@HiveField(19)
final String? userMessageID; final String? userMessageID;
@HiveField(20)
final String usedKeyID; final String usedKeyID;
@HiveField(21)
final bool trimmed; final bool trimmed;
const Message({ Message({
required this.messageID, required this.messageID,
required this.senderUserID, required this.senderUserID,
required this.channelInternalName, required this.channelInternalName,
@ -54,4 +74,27 @@ class Message {
return (npt, messages); return (npt, messages);
} }
@override
String toString() {
return 'Message[${this.messageID}]';
}
List<(String, String)> debugFieldList() {
return [
('messageID', this.messageID),
('senderUserID', this.senderUserID),
('channelInternalName', this.channelInternalName),
('channelID', this.channelID),
('senderName', this.senderName ?? ''),
('senderIP', this.senderIP),
('timestamp', this.timestamp),
('title', this.title),
('content', this.content ?? ''),
('priority', '${this.priority}'),
('userMessageID', this.userMessageID ?? ''),
('usedKeyID', this.usedKeyID),
('trimmed', '${this.trimmed}'),
];
}
} }

View File

@ -0,0 +1,77 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'message.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class MessageAdapter extends TypeAdapter<Message> {
@override
final int typeId = 105;
@override
Message read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return Message(
messageID: fields[0] as String,
senderUserID: fields[10] as String,
channelInternalName: fields[11] as String,
channelID: fields[12] as String,
senderName: fields[13] as String?,
senderIP: fields[14] as String,
timestamp: fields[15] as String,
title: fields[16] as String,
content: fields[17] as String?,
priority: fields[18] as int,
userMessageID: fields[19] as String?,
usedKeyID: fields[20] as String,
trimmed: fields[21] as bool,
);
}
@override
void write(BinaryWriter writer, Message obj) {
writer
..writeByte(13)
..writeByte(0)
..write(obj.messageID)
..writeByte(10)
..write(obj.senderUserID)
..writeByte(11)
..write(obj.channelInternalName)
..writeByte(12)
..write(obj.channelID)
..writeByte(13)
..write(obj.senderName)
..writeByte(14)
..write(obj.senderIP)
..writeByte(15)
..write(obj.timestamp)
..writeByte(16)
..write(obj.title)
..writeByte(17)
..write(obj.content)
..writeByte(18)
..write(obj.priority)
..writeByte(19)
..write(obj.userMessageID)
..writeByte(20)
..write(obj.usedKeyID)
..writeByte(21)
..write(obj.trimmed);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is MessageAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@ -90,3 +90,20 @@ class UserWithClientsAndKeys {
); );
} }
} }
class UserPreview {
final String userID;
final String? username;
const UserPreview({
required this.userID,
required this.username,
});
factory UserPreview.fromJson(Map<String, dynamic> json) {
return UserPreview(
userID: json['user_id'] as String,
username: json['username'] as String?,
);
}
}

View File

@ -66,11 +66,11 @@ class _SCNNavLayoutState extends State<SCNNavLayout> {
), ),
body: IndexedStack( body: IndexedStack(
children: [ children: [
ExcludeFocus(excluding: _selectedIndex != 0, child: MessageListPage()), ExcludeFocus(excluding: _selectedIndex != 0, child: MessageListPage(isVisiblePage: _selectedIndex == 0)),
ExcludeFocus(excluding: _selectedIndex != 1, child: ChannelRootPage()), ExcludeFocus(excluding: _selectedIndex != 1, child: ChannelRootPage(isVisiblePage: _selectedIndex == 1)),
ExcludeFocus(excluding: _selectedIndex != 2, child: AccountRootPage()), ExcludeFocus(excluding: _selectedIndex != 2, child: AccountRootPage(isVisiblePage: _selectedIndex == 2)),
ExcludeFocus(excluding: _selectedIndex != 3, child: SettingsRootPage()), ExcludeFocus(excluding: _selectedIndex != 3, child: SettingsRootPage(isVisiblePage: _selectedIndex == 3)),
ExcludeFocus(excluding: _selectedIndex != 4, child: SendRootPage()), ExcludeFocus(excluding: _selectedIndex != 4, child: SendRootPage(isVisiblePage: _selectedIndex == 4)),
], ],
index: _selectedIndex, index: _selectedIndex,
), ),

View File

@ -7,47 +7,81 @@ 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/state/app_bar_state.dart';
import 'package:simplecloudnotifier/state/application_log.dart'; import 'package:simplecloudnotifier/state/application_log.dart';
import 'package:simplecloudnotifier/state/globals.dart'; import 'package:simplecloudnotifier/state/globals.dart';
import 'package:simplecloudnotifier/state/app_auth.dart'; import 'package:simplecloudnotifier/state/app_auth.dart';
import 'package:simplecloudnotifier/types/immediate_future.dart';
import 'package:simplecloudnotifier/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';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
class AccountRootPage extends StatefulWidget { class AccountRootPage extends StatefulWidget {
const AccountRootPage({super.key}); const AccountRootPage({super.key, required this.isVisiblePage});
final bool isVisiblePage;
@override @override
State<AccountRootPage> createState() => _AccountRootPageState(); State<AccountRootPage> createState() => _AccountRootPageState();
} }
class _AccountRootPageState extends State<AccountRootPage> { class _AccountRootPageState extends State<AccountRootPage> {
late Future<int>? futureSubscriptionCount; late ImmediateFuture<int>? futureSubscriptionCount;
late Future<int>? futureClientCount; late ImmediateFuture<int>? futureClientCount;
late Future<int>? futureKeyCount; late ImmediateFuture<int>? futureKeyCount;
late Future<int>? futureChannelAllCount; late ImmediateFuture<int>? futureChannelAllCount;
late Future<int>? futureChannelSubscribedCount; late ImmediateFuture<int>? futureChannelSubscribedCount;
late ImmediateFuture<User>? futureUser;
late AppAuth userAcc; late AppAuth userAcc;
bool loading = false; bool loading = false;
bool _isInitialized = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
userAcc = Provider.of<AppAuth>(context, listen: false); userAcc = Provider.of<AppAuth>(context, listen: false);
userAcc.addListener(_onAuthStateChanged); userAcc.addListener(_onAuthStateChanged);
if (widget.isVisiblePage && !_isInitialized) _realInitState();
}
@override
void didUpdateWidget(AccountRootPage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.isVisiblePage != widget.isVisiblePage && widget.isVisiblePage) {
if (!_isInitialized) {
_realInitState();
} else {
_backgroundRefresh();
}
}
}
void _realInitState() {
ApplicationLog.debug('AccountRootPage::_realInitState');
_onAuthStateChanged(); _onAuthStateChanged();
_isInitialized = true;
} }
@override @override
void dispose() { void dispose() {
ApplicationLog.debug('AccountRootPage::dispose');
userAcc.removeListener(_onAuthStateChanged); userAcc.removeListener(_onAuthStateChanged);
super.dispose(); super.dispose();
} }
void _onAuthStateChanged() { void _onAuthStateChanged() {
ApplicationLog.debug('AccountRootPage::_onAuthStateChanged');
_createFutures();
}
void _createFutures() {
futureSubscriptionCount = null; futureSubscriptionCount = null;
futureClientCount = null; futureClientCount = null;
futureKeyCount = null; futureKeyCount = null;
@ -55,35 +89,70 @@ class _AccountRootPageState extends State<AccountRootPage> {
futureChannelSubscribedCount = null; futureChannelSubscribedCount = null;
if (userAcc.isAuth()) { if (userAcc.isAuth()) {
futureChannelAllCount = () async { futureChannelAllCount = ImmediateFuture.ofFuture(() async {
if (!userAcc.isAuth()) throw new Exception('not logged in'); if (!userAcc.isAuth()) throw new Exception('not logged in');
final channels = await APIClient.getChannelList(userAcc, ChannelSelector.all); final channels = await APIClient.getChannelList(userAcc, ChannelSelector.all);
return channels.length; return channels.length;
}(); }());
futureChannelSubscribedCount = () async { futureChannelSubscribedCount = ImmediateFuture.ofFuture(() async {
if (!userAcc.isAuth()) throw new Exception('not logged in'); if (!userAcc.isAuth()) throw new Exception('not logged in');
final channels = await APIClient.getChannelList(userAcc, ChannelSelector.subscribed); final channels = await APIClient.getChannelList(userAcc, ChannelSelector.subscribed);
return channels.length; return channels.length;
}(); }());
futureSubscriptionCount = () async { futureSubscriptionCount = ImmediateFuture.ofFuture(() async {
if (!userAcc.isAuth()) throw new Exception('not logged in'); if (!userAcc.isAuth()) throw new Exception('not logged in');
final subs = await APIClient.getSubscriptionList(userAcc); final subs = await APIClient.getSubscriptionList(userAcc);
return subs.length; return subs.length;
}(); }());
futureClientCount = () async { futureClientCount = ImmediateFuture.ofFuture(() async {
if (!userAcc.isAuth()) throw new Exception('not logged in'); if (!userAcc.isAuth()) throw new Exception('not logged in');
final clients = await APIClient.getClientList(userAcc); final clients = await APIClient.getClientList(userAcc);
return clients.length; return clients.length;
}(); }());
futureKeyCount = () async { futureKeyCount = ImmediateFuture.ofFuture(() async {
if (!userAcc.isAuth()) throw new Exception('not logged in'); if (!userAcc.isAuth()) throw new Exception('not logged in');
final keys = await APIClient.getKeyTokenList(userAcc); final keys = await APIClient.getKeyTokenList(userAcc);
return keys.length; return keys.length;
}(); }());
futureUser = ImmediateFuture.ofFuture(userAcc.loadUser(force: false));
}
}
Future<void> _backgroundRefresh() async {
if (userAcc.isAuth()) {
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);
// refresh all data and then replace teh futures used in build()
final channelsAll = await APIClient.getChannelList(userAcc, ChannelSelector.all);
final channelsSubscribed = await APIClient.getChannelList(userAcc, ChannelSelector.subscribed);
final subs = await APIClient.getSubscriptionList(userAcc);
final clients = await APIClient.getClientList(userAcc);
final keys = await APIClient.getKeyTokenList(userAcc);
final user = await userAcc.loadUser(force: true);
setState(() {
futureChannelAllCount = ImmediateFuture.ofValue(channelsAll.length);
futureChannelSubscribedCount = ImmediateFuture.ofValue(channelsSubscribed.length);
futureSubscriptionCount = ImmediateFuture.ofValue(subs.length);
futureClientCount = ImmediateFuture.ofValue(clients.length);
futureKeyCount = ImmediateFuture.ofValue(keys.length);
futureUser = ImmediateFuture.ofValue(user);
});
} catch (exc, trace) {
ApplicationLog.error('Failed to refresh account data: ' + exc.toString(), trace: trace);
Toaster.error("Error", 'Failed to refresh account data');
} finally {
AppBarState().setLoadingIndeterminate(false);
}
} }
} }
@ -91,19 +160,23 @@ class _AccountRootPageState extends State<AccountRootPage> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Consumer<AppAuth>( return Consumer<AppAuth>(
builder: (context, acc, child) { builder: (context, acc, child) {
if (!_isInitialized) return SizedBox();
if (!userAcc.isAuth()) { if (!userAcc.isAuth()) {
return _buildNoAuth(context); return _buildNoAuth(context);
} else { } else {
return FutureBuilder( return FutureBuilder(
future: acc.loadUser(force: false), future: futureUser!.future,
builder: ((context, snapshot) { builder: ((context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) { if (futureUser?.value != null) {
if (snapshot.hasError) { return _buildShowAccount(context, acc, futureUser!.value!);
} else if (snapshot.connectionState == ConnectionState.done && snapshot.hasError) {
return Text('Error: ${snapshot.error}'); //TODO better error display return Text('Error: ${snapshot.error}'); //TODO better error display
} } else if (snapshot.connectionState == ConnectionState.done) {
return _buildShowAccount(context, acc, snapshot.data!); return _buildShowAccount(context, acc, snapshot.data!);
} } else {
return Center(child: CircularProgressIndicator()); return Center(child: CircularProgressIndicator());
}
}), }),
); );
} }
@ -157,7 +230,7 @@ class _AccountRootPageState extends State<AccountRootPage> {
text: 'Use existing account', text: 'Use existing account',
onPressed: () { onPressed: () {
if (loading) return; if (loading) return;
Navigator.push(context, MaterialPageRoute<AccountLoginPage>(builder: (context) => AccountLoginPage())); Navi.push(context, () => AccountLoginPage());
}, },
tonal: true, tonal: true,
big: true, big: true,
@ -255,12 +328,15 @@ class _AccountRootPageState extends State<AccountRootPage> {
children: [ children: [
SizedBox(width: 80, child: Text("Channels", style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(160)))), SizedBox(width: 80, child: Text("Channels", style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(160)))),
FutureBuilder( FutureBuilder(
future: futureChannelAllCount, future: futureChannelAllCount!.future,
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) { if (futureChannelAllCount?.value != null) {
return Text('${futureChannelAllCount!.value}');
} else if (snapshot.connectionState == ConnectionState.done) {
return Text('${snapshot.data}'); return Text('${snapshot.data}');
} } else {
return const SizedBox(width: 8, height: 8, child: Center(child: CircularProgressIndicator())); return const SizedBox(width: 8, height: 8, child: Center(child: CircularProgressIndicator()));
}
}, },
) )
], ],
@ -289,86 +365,10 @@ class _AccountRootPageState extends State<AccountRootPage> {
List<Widget> _buildCards(BuildContext context, User user) { List<Widget> _buildCards(BuildContext context, User user) {
return [ return [
UI.buttonCard( _buildNumberCard(context, 'Subscriptions', futureSubscriptionCount, () {/*TODO*/}),
context: context, _buildNumberCard(context, 'Clients', futureClientCount, () {/*TODO*/}),
margin: EdgeInsets.fromLTRB(0, 4, 0, 4), _buildNumberCard(context, 'Keys', futureKeyCount, () {/*TODO*/}),
child: Row( _buildNumberCard(context, 'Channels', futureChannelSubscribedCount, () {/*TODO*/}),
children: [
FutureBuilder(
future: futureSubscriptionCount,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
return Text('${snapshot.data}', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20));
}
return const SizedBox(width: 12, height: 12, child: Center(child: CircularProgressIndicator()));
},
),
const SizedBox(width: 12),
Text('Subscriptions', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20)),
],
),
onTap: () {/*TODO*/},
),
UI.buttonCard(
context: context,
margin: EdgeInsets.fromLTRB(0, 4, 0, 4),
child: Row(
children: [
FutureBuilder(
future: futureClientCount,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
return Text('${snapshot.data}', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20));
}
return const SizedBox(width: 12, height: 12, child: Center(child: CircularProgressIndicator()));
},
),
const SizedBox(width: 12),
Text('Clients', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20)),
],
),
onTap: () {/*TODO*/},
),
UI.buttonCard(
context: context,
margin: EdgeInsets.fromLTRB(0, 4, 0, 4),
child: Row(
children: [
FutureBuilder(
future: futureKeyCount,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
return Text('${snapshot.data}', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20));
}
return const SizedBox(width: 12, height: 12, child: Center(child: CircularProgressIndicator()));
},
),
const SizedBox(width: 12),
Text('Keys', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20)),
],
),
onTap: () {/*TODO*/},
),
UI.buttonCard(
context: context,
margin: EdgeInsets.fromLTRB(0, 4, 0, 4),
child: Row(
children: [
FutureBuilder(
future: futureChannelSubscribedCount,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
return Text('${snapshot.data}', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20));
}
return const SizedBox(width: 12, height: 12, child: Center(child: CircularProgressIndicator()));
},
),
const SizedBox(width: 12),
Text('Channels', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20)),
],
),
onTap: () {/*TODO*/},
),
UI.buttonCard( UI.buttonCard(
context: context, context: context,
margin: EdgeInsets.fromLTRB(0, 4, 0, 4), margin: EdgeInsets.fromLTRB(0, 4, 0, 4),
@ -384,6 +384,32 @@ class _AccountRootPageState extends State<AccountRootPage> {
]; ];
} }
Widget _buildNumberCard(BuildContext context, String txt, ImmediateFuture<int>? future, void Function() action) {
return UI.buttonCard(
context: context,
margin: EdgeInsets.fromLTRB(0, 4, 0, 4),
child: Row(
children: [
FutureBuilder(
future: future?.future,
builder: (context, snapshot) {
if (future?.value != null) {
return Text('${future?.value}', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20));
} else if (snapshot.connectionState == ConnectionState.done) {
return Text('${snapshot.data}', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20));
} else {
return const SizedBox(width: 12, height: 12, child: Center(child: CircularProgressIndicator()));
}
},
),
const SizedBox(width: 12),
Text(txt, style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20)),
],
),
onTap: action,
);
}
Widget _buildFooter(BuildContext context, User user) { Widget _buildFooter(BuildContext context, User user) {
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 0), padding: const EdgeInsets.fromLTRB(8, 0, 8, 0),

View File

@ -8,6 +8,7 @@ import 'package:simplecloudnotifier/state/application_log.dart';
import 'package:simplecloudnotifier/state/globals.dart'; import 'package:simplecloudnotifier/state/globals.dart';
import 'package:simplecloudnotifier/state/app_auth.dart'; import 'package:simplecloudnotifier/state/app_auth.dart';
import 'package:simplecloudnotifier/state/token_source.dart'; import 'package:simplecloudnotifier/state/token_source.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';
@ -154,7 +155,7 @@ class _AccountLoginPageState extends State<AccountLoginPage> {
await acc.save(); await acc.save();
Toaster.success("Login", "Successfully logged in"); Toaster.success("Login", "Successfully logged in");
Navigator.popUntil(context, (route) => route.isFirst); Navi.popToRoot(context);
} catch (exc, trace) { } catch (exc, trace) {
ApplicationLog.error('Failed to verify token: ' + exc.toString(), trace: trace); ApplicationLog.error('Failed to verify token: ' + exc.toString(), trace: trace);
Toaster.error("Error", 'Failed to verify token'); Toaster.error("Error", 'Failed to verify token');

View File

@ -3,37 +3,65 @@ 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/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';
class ChannelRootPage extends StatefulWidget { class ChannelRootPage extends StatefulWidget {
const ChannelRootPage({super.key}); const ChannelRootPage({super.key, required this.isVisiblePage});
final bool isVisiblePage;
@override @override
State<ChannelRootPage> createState() => _ChannelRootPageState(); State<ChannelRootPage> createState() => _ChannelRootPageState();
} }
class _ChannelRootPageState extends State<ChannelRootPage> { class _ChannelRootPageState extends State<ChannelRootPage> {
final PagingController<int, Channel> _pagingController = PagingController(firstPageKey: 0); final PagingController<int, Channel> _pagingController = PagingController.fromValue(PagingState(nextPageKey: null, itemList: [], error: null), firstPageKey: 0);
bool _isInitialized = false;
@override @override
void initState() { void initState() {
_pagingController.addPageRequestListener((pageKey) {
_fetchPage(pageKey);
});
super.initState(); super.initState();
_pagingController.addPageRequestListener(_fetchPage);
if (widget.isVisiblePage && !_isInitialized) _realInitState();
} }
@override @override
void dispose() { void dispose() {
ApplicationLog.debug('ChannelRootPage::dispose');
_pagingController.dispose(); _pagingController.dispose();
super.dispose(); super.dispose();
} }
@override
void didUpdateWidget(ChannelRootPage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.isVisiblePage != widget.isVisiblePage && widget.isVisiblePage) {
if (!_isInitialized) {
_realInitState();
} else {
_backgroundRefresh();
}
}
}
void _realInitState() {
ApplicationLog.debug('ChannelRootPage::_realInitState');
_pagingController.refresh();
_isInitialized = true;
}
Future<void> _fetchPage(int pageKey) async { Future<void> _fetchPage(int pageKey) async {
final acc = Provider.of<AppAuth>(context, listen: false); final acc = Provider.of<AppAuth>(context, listen: false);
ApplicationLog.debug('Start ChannelList::_pagingController::_fetchPage [ ${pageKey} ]');
if (!acc.isAuth()) { if (!acc.isAuth()) {
_pagingController.error = 'Not logged in'; _pagingController.error = 'Not logged in';
return; return;
@ -44,13 +72,41 @@ class _ChannelRootPageState extends State<ChannelRootPage> {
items.sort((a, b) => -1 * (a.timestampLastSent ?? '').compareTo(b.timestampLastSent ?? '')); items.sort((a, b) => -1 * (a.timestampLastSent ?? '').compareTo(b.timestampLastSent ?? ''));
_pagingController.appendLastPage(items); _pagingController.value = PagingState(nextPageKey: null, itemList: items, error: null);
} catch (exc, trace) { } catch (exc, trace) {
_pagingController.error = exc.toString(); _pagingController.error = exc.toString();
ApplicationLog.error('Failed to list channels: ' + exc.toString(), trace: trace); 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)).map((p) => p.channel).toList();
items.sort((a, b) => -1 * (a.timestampLastSent ?? '').compareTo(b.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);
} finally {
AppBarState().setLoadingIndeterminate(false);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return RefreshIndicator( return RefreshIndicator(

View File

@ -1,10 +1,15 @@
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/message.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/interfaces.dart';
import 'package:simplecloudnotifier/state/request_log.dart'; import 'package:simplecloudnotifier/state/request_log.dart';
import 'package:simplecloudnotifier/utils/navi.dart';
class DebugPersistencePage extends StatefulWidget { class DebugPersistencePage extends StatefulWidget {
@override @override
@ -28,62 +33,56 @@ class _DebugPersistencePageState extends State<DebugPersistencePage> {
mainAxisAlignment: MainAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Card.outlined( _buildSharedPrefCard(context),
_buildHiveCard(context, () => Hive.box<SCNRequest>('scn-requests'), 'scn-requests'),
_buildHiveCard(context, () => Hive.box<SCNLog>('scn-logs'), 'scn-logs'),
_buildHiveCard(context, () => Hive.box<Message>('scn-message-cache'), 'scn-message-cache'),
_buildHiveCard(context, () => Hive.box<Channel>('scn-channel-cache'), 'scn-channel-cache'),
_buildHiveCard(context, () => Hive.box<FBMessage>('scn-fb-messages'), 'scn-fb-messages'),
],
),
);
}
Widget _buildSharedPrefCard(BuildContext context) {
return Card.outlined(
child: Padding( child: Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: GestureDetector( child: GestureDetector(
onTap: () { onTap: () {
Navigator.push(context, MaterialPageRoute<DebugSharedPrefPage>(builder: (context) => DebugSharedPrefPage(sharedPref: prefs!))); Navi.push(context, () => DebugSharedPrefPage(sharedPref: prefs!));
}, },
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
SizedBox(width: 30, child: Text('')), SizedBox(width: 30, child: Text('')),
Expanded(child: Text('Shared Preferences', style: TextStyle(fontWeight: FontWeight.bold), textAlign: TextAlign.center)), Expanded(child: Text('Shared Preferences', style: TextStyle(fontWeight: FontWeight.bold), textAlign: TextAlign.center)),
SizedBox(width: 30, child: Text('${prefs?.getKeys().length.toString()}', textAlign: TextAlign.end)), SizedBox(width: 40, child: Text('${prefs?.getKeys().length.toString()}', textAlign: TextAlign.end)),
], ],
), ),
), ),
), ),
), );
Card.outlined( }
Widget _buildHiveCard(BuildContext context, Box<FieldDebuggable> Function() boxFunc, String boxname) {
return Card.outlined(
child: Padding( child: Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: GestureDetector( child: GestureDetector(
onTap: () { onTap: () {
Navigator.push(context, MaterialPageRoute<DebugHiveBoxPage>(builder: (context) => DebugHiveBoxPage(boxName: 'scn-requests', box: Hive.box<SCNRequest>('scn-requests')))); Navi.push(context, () => DebugHiveBoxPage(boxName: boxname, box: Hive.box<FBMessage>(boxname)));
}, },
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
SizedBox(width: 30, child: Text('')), SizedBox(width: 30, child: Text('')),
Expanded(child: Text('Hive [scn-requests]', style: TextStyle(fontWeight: FontWeight.bold), textAlign: TextAlign.center)), Expanded(child: Text('Hive [$boxname]', style: TextStyle(fontWeight: FontWeight.bold), textAlign: TextAlign.center)),
SizedBox(width: 30, child: Text('${Hive.box<SCNRequest>('scn-requests').length.toString()}', textAlign: TextAlign.end)), SizedBox(width: 40, child: Text('${boxFunc().length.toString()}', textAlign: TextAlign.end)),
], ],
), ),
), ),
), ),
),
Card.outlined(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: GestureDetector(
onTap: () {
Navigator.push(context, MaterialPageRoute<DebugHiveBoxPage>(builder: (context) => DebugHiveBoxPage(boxName: 'scn-requests', box: Hive.box<SCNLog>('scn-logs'))));
},
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
SizedBox(width: 30, child: Text('')),
Expanded(child: Text('Hive [scn-logs]', style: TextStyle(fontWeight: FontWeight.bold), textAlign: TextAlign.center)),
SizedBox(width: 30, child: Text('${Hive.box<SCNLog>('scn-logs').length.toString()}', textAlign: TextAlign.end)),
],
),
),
),
),
],
),
); );
} }
} }

View File

@ -3,6 +3,7 @@ import 'package:hive_flutter/hive_flutter.dart';
import 'package:simplecloudnotifier/components/layout/scaffold.dart'; import 'package:simplecloudnotifier/components/layout/scaffold.dart';
import 'package:simplecloudnotifier/pages/debug/debug_persistence_hiveentry.dart'; import 'package:simplecloudnotifier/pages/debug/debug_persistence_hiveentry.dart';
import 'package:simplecloudnotifier/state/interfaces.dart'; import 'package:simplecloudnotifier/state/interfaces.dart';
import 'package:simplecloudnotifier/utils/navi.dart';
class DebugHiveBoxPage extends StatelessWidget { class DebugHiveBoxPage extends StatelessWidget {
final String boxName; final String boxName;
@ -21,7 +22,7 @@ class DebugHiveBoxPage extends StatelessWidget {
itemBuilder: (context, listIndex) { itemBuilder: (context, listIndex) {
return GestureDetector( return GestureDetector(
onTap: () { onTap: () {
Navigator.push(context, MaterialPageRoute<DebugHiveEntryPage>(builder: (context) => DebugHiveEntryPage(value: box.getAt(listIndex)!))); Navi.push(context, () => DebugHiveEntryPage(value: box.getAt(listIndex)!));
}, },
child: ListTile( child: ListTile(
title: Text(box.getAt(listIndex).toString(), style: TextStyle(fontWeight: FontWeight.bold)), title: Text(box.getAt(listIndex).toString(), style: TextStyle(fontWeight: FontWeight.bold)),

View File

@ -3,6 +3,7 @@ import 'package:hive_flutter/hive_flutter.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:simplecloudnotifier/pages/debug/debug_request_view.dart'; import 'package:simplecloudnotifier/pages/debug/debug_request_view.dart';
import 'package:simplecloudnotifier/state/request_log.dart'; import 'package:simplecloudnotifier/state/request_log.dart';
import 'package:simplecloudnotifier/utils/navi.dart';
class DebugRequestsPage extends StatefulWidget { class DebugRequestsPage extends StatefulWidget {
@override @override
@ -40,7 +41,7 @@ class _DebugRequestsPageState extends State<DebugRequestsPage> {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 2.0), padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 2.0),
child: GestureDetector( child: GestureDetector(
onTap: () => Navigator.push(context, MaterialPageRoute<DebugRequestViewPage>(builder: (context) => DebugRequestViewPage(request: req))), onTap: () => Navi.push(context, () => DebugRequestViewPage(request: req)),
child: ListTile( child: ListTile(
tileColor: Theme.of(context).colorScheme.errorContainer, tileColor: Theme.of(context).colorScheme.errorContainer,
textColor: Theme.of(context).colorScheme.onErrorContainer, textColor: Theme.of(context).colorScheme.onErrorContainer,
@ -76,7 +77,7 @@ class _DebugRequestsPageState extends State<DebugRequestsPage> {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 2.0), padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 2.0),
child: GestureDetector( child: GestureDetector(
onTap: () => Navigator.push(context, MaterialPageRoute<DebugRequestViewPage>(builder: (context) => DebugRequestViewPage(request: req))), onTap: () => Navi.push(context, () => DebugRequestViewPage(request: req)),
child: ListTile( child: ListTile(
title: Row( title: Row(
children: [ children: [

View File

@ -1,16 +1,21 @@
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/message.dart';
import 'package:simplecloudnotifier/pages/message_view/message_view.dart'; import 'package:simplecloudnotifier/pages/message_view/message_view.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/message_list/message_list_item.dart'; import 'package:simplecloudnotifier/pages/message_list/message_list_item.dart';
import 'package:simplecloudnotifier/utils/navi.dart';
class MessageListPage extends StatefulWidget { class MessageListPage extends StatefulWidget {
const MessageListPage({super.key}); const MessageListPage({super.key, required this.isVisiblePage});
final bool isVisiblePage;
//TODO reload on switch to tab //TODO reload on switch to tab
//TODO reload on app to foreground //TODO reload on app to foreground
@ -19,31 +24,104 @@ class MessageListPage extends StatefulWidget {
State<MessageListPage> createState() => _MessageListPageState(); State<MessageListPage> createState() => _MessageListPageState();
} }
class _MessageListPageState extends State<MessageListPage> { class _MessageListPageState extends State<MessageListPage> with RouteAware {
static const _pageSize = 128; static const _pageSize = 128;
final PagingController<String, Message> _pagingController = PagingController(firstPageKey: '@start'); late final AppLifecycleListener _lifecyleListener;
PagingController<String, Message> _pagingController = PagingController.fromValue(PagingState(nextPageKey: null, itemList: [], error: null), firstPageKey: '@start');
Map<String, Channel>? _channels = null; Map<String, Channel>? _channels = null;
bool _isInitialized = false;
@override @override
void initState() { void initState() {
//TODO init with state from cache - also allow tho show cache on error
_pagingController.addPageRequestListener((pageKey) {
_fetchPage(pageKey);
});
super.initState(); super.initState();
_pagingController.addPageRequestListener(_fetchPage);
if (widget.isVisiblePage && !_isInitialized) _realInitState();
_lifecyleListener = AppLifecycleListener(
onResume: _onLifecycleResume,
);
}
@override
void didUpdateWidget(MessageListPage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.isVisiblePage != widget.isVisiblePage && widget.isVisiblePage) {
if (!_isInitialized) {
_realInitState();
} else {
_backgroundRefresh(false);
}
}
}
void _realInitState() {
ApplicationLog.debug('MessageListPage::_realInitState');
final chnCache = Hive.box<Channel>('scn-channel-cache');
final msgCache = Hive.box<Message>('scn-message-cache');
if (chnCache.isNotEmpty && msgCache.isNotEmpty) {
// ==== Use cache values - and refresh in background
_channels = <String, Channel>{for (var v in chnCache.values) v.channelID: v};
final cacheMessages = msgCache.values.toList();
cacheMessages.sort((a, b) => -1 * a.timestamp.compareTo(b.timestamp));
_pagingController.value = PagingState(nextPageKey: null, itemList: cacheMessages, error: null);
_backgroundRefresh(true);
} else {
// ==== Full refresh - no cache available
_pagingController.refresh();
}
_isInitialized = true;
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
Navi.modalRouteObserver.subscribe(this, ModalRoute.of(context)!);
} }
@override @override
void dispose() { void dispose() {
ApplicationLog.debug('MessageListPage::dispose');
Navi.modalRouteObserver.unsubscribe(this);
_pagingController.dispose(); _pagingController.dispose();
_lifecyleListener.dispose();
super.dispose(); super.dispose();
} }
@override
void didPush() {
// ...
}
@override
void didPopNext() {
ApplicationLog.debug('[MessageList::RouteObserver] --> didPopNext (will background-refresh)');
_backgroundRefresh(false);
}
void _onLifecycleResume() {
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);
ApplicationLog.debug('Start MessageList::_pagingController::_fetchPage [ ${thisPageToken} ]');
if (!acc.isAuth()) { if (!acc.isAuth()) {
_pagingController.error = 'Not logged in'; _pagingController.error = 'Not logged in';
return; return;
@ -53,10 +131,16 @@ class _MessageListPageState extends State<MessageListPage> {
if (_channels == null) { if (_channels == null) {
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
} }
final (npt, newItems) = await APIClient.getMessageList(acc, thisPageToken, pageSize: _pageSize); final (npt, newItems) = await APIClient.getMessageList(acc, thisPageToken, pageSize: _pageSize);
_addToMessageCache(newItems); // no await
ApplicationLog.debug('Finished MessageList::_pagingController::_fetchPage [ ${newItems.length} items and npt: ${thisPageToken} --> ${npt} ]');
if (npt == '@end') { if (npt == '@end') {
_pagingController.appendLastPage(newItems); _pagingController.appendLastPage(newItems);
} else { } else {
@ -68,6 +152,71 @@ class _MessageListPageState extends State<MessageListPage> {
} }
} }
Future<void> _backgroundRefresh(bool fullReplaceState) async {
final acc = Provider.of<AppAuth>(context, listen: false);
ApplicationLog.debug('Start background refresh of message list (fullReplaceState: $fullReplaceState)');
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);
if (_channels == null || fullReplaceState) {
final channels = await APIClient.getChannelList(acc, ChannelSelector.allAny);
setState(() {
_channels = <String, Channel>{for (var v in channels) v.channel.channelID: v.channel};
});
_setChannelCache(channels); // no await
}
final (npt, newItems) = await APIClient.getMessageList(acc, '@start', pageSize: _pageSize);
_addToMessageCache(newItems); // no await
if (fullReplaceState) {
// fully replace/reset state
ApplicationLog.debug('Background-refresh finished (fullReplaceState) - replace state with ${newItems.length} items and npt: [ $npt ]');
setState(() {
if (npt == '@end')
_pagingController.value = PagingState(nextPageKey: null, itemList: newItems, error: null);
else
_pagingController.value = PagingState(nextPageKey: npt, itemList: newItems, error: null);
});
} else {
final itemsToBeAdded = newItems.where((p1) => !(_pagingController.itemList ?? []).any((p2) => p1.messageID == p2.messageID)).toList();
if (itemsToBeAdded.isEmpty) {
// nothing to do - no new items...
// ....
ApplicationLog.debug('Background-refresh returned no new items - nothing to do.');
} else if (itemsToBeAdded.length == newItems.length) {
// all items are new ?!?, the current state is completely fucked - full replace
ApplicationLog.debug('Background-refresh found only new items ?!? - fully replace state with ${newItems.length} items');
setState(() {
if (npt == '@end')
_pagingController.value = PagingState(nextPageKey: null, itemList: newItems, error: null);
else
_pagingController.value = PagingState(nextPageKey: npt, itemList: newItems, error: null);
_pagingController.itemList = null;
});
} else {
// add new items to the front
ApplicationLog.debug('Background-refresh found ${newItems.length} new items - add to front');
setState(() {
_pagingController.itemList = itemsToBeAdded + (_pagingController.itemList ?? []);
});
}
}
} catch (exc, trace) {
setState(() {
_pagingController.error = exc.toString();
});
ApplicationLog.error('Failed to list messages: ' + exc.toString(), trace: trace);
} finally {
AppBarState().setLoadingIndeterminate(false);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return Padding(
@ -83,7 +232,7 @@ class _MessageListPageState extends State<MessageListPage> {
message: item, message: item,
allChannels: _channels ?? {}, allChannels: _channels ?? {},
onPressed: () { onPressed: () {
Navigator.push(context, MaterialPageRoute<MessageViewPage>(builder: (context) => MessageViewPage(message: item))); Navi.push(context, () => MessageViewPage(message: item));
}, },
), ),
), ),
@ -91,4 +240,30 @@ class _MessageListPageState extends State<MessageListPage> {
), ),
); );
} }
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);
}
Future<void> _addToMessageCache(List<Message> newItems) async {
final cache = Hive.box<Message>('scn-message-cache');
for (var msg in newItems) await cache.put(msg.messageID, msg);
// delete all but the newest 128 messages
if (cache.length < _pageSize) return;
final allValues = cache.values.toList();
allValues.sort((a, b) => -1 * a.timestamp.compareTo(b.timestamp));
for (var val in allValues.sublist(_pageSize)) {
await cache.delete(val.messageID);
}
}
} }

View File

@ -48,10 +48,6 @@ class MessageListItem extends StatelessWidget {
Row( Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (message.priority == 2) FaIcon(FontAwesomeIcons.solidTriangleExclamation, size: 16, color: Colors.red[900]),
if (message.priority == 2) SizedBox(width: 4),
if (message.priority == 0) FaIcon(FontAwesomeIcons.solidDown, size: 16, color: Colors.lightBlue[900]),
if (message.priority == 0) SizedBox(width: 4),
Expanded( Expanded(
child: Text( child: Text(
processTitle(message.title), processTitle(message.title),
@ -69,12 +65,23 @@ class MessageListItem extends StatelessWidget {
], ],
), ),
SizedBox(height: 4), SizedBox(height: 4),
Text( Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Text(
processContent(message.content), processContent(message.content),
style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(160)), style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(160)),
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
maxLines: _lineCount, maxLines: _lineCount,
), ),
),
if (message.priority == 2) SizedBox(width: 4),
if (message.priority == 2) FaIcon(FontAwesomeIcons.solidTriangleExclamation, size: 16, color: Colors.red[900]),
if (message.priority == 0) SizedBox(width: 4),
if (message.priority == 0) FaIcon(FontAwesomeIcons.solidDown, size: 16, color: Colors.lightBlue[900]),
],
),
], ],
), ),
), ),
@ -99,10 +106,6 @@ class MessageListItem extends StatelessWidget {
Row( Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (message.priority == 2) FaIcon(FontAwesomeIcons.solidTriangleExclamation, size: 16, color: Colors.red[900]),
if (message.priority == 2) SizedBox(width: 4),
if (message.priority == 0) FaIcon(FontAwesomeIcons.solidDown, size: 16, color: Colors.lightBlue[900]),
if (message.priority == 0) SizedBox(width: 4),
UI.channelChip( UI.channelChip(
context: context, context: context,
text: resolveChannelName(message), text: resolveChannelName(message),
@ -124,12 +127,23 @@ class MessageListItem extends StatelessWidget {
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
maxLines: 3, maxLines: 3,
), ),
Text( Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Text(
processContent(message.content), processContent(message.content),
style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(160)), style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(160)),
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
maxLines: _lineCount, maxLines: _lineCount,
), ),
),
if (message.priority == 2) SizedBox(width: 4),
if (message.priority == 2) FaIcon(FontAwesomeIcons.solidTriangleExclamation, size: 16, color: Colors.red[900]),
if (message.priority == 0) SizedBox(width: 4),
if (message.priority == 0) FaIcon(FontAwesomeIcons.solidDown, size: 16, color: Colors.lightBlue[900]),
],
),
], ],
), ),
), ),

View File

@ -5,13 +5,13 @@ import 'package:intl/intl.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
import 'package:simplecloudnotifier/api/api_client.dart'; import 'package:simplecloudnotifier/api/api_client.dart';
import 'package:simplecloudnotifier/api/api_exception.dart';
import 'package:simplecloudnotifier/components/layout/scaffold.dart'; import 'package:simplecloudnotifier/components/layout/scaffold.dart';
import 'package:simplecloudnotifier/models/api_error.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/message.dart';
import 'package:simplecloudnotifier/models/user.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/utils/toaster.dart'; import 'package:simplecloudnotifier/utils/toaster.dart';
import 'package:simplecloudnotifier/utils/ui.dart'; import 'package:simplecloudnotifier/utils/ui.dart';
@ -25,54 +25,46 @@ class MessageViewPage extends StatefulWidget {
} }
class _MessageViewPageState extends State<MessageViewPage> { class _MessageViewPageState extends State<MessageViewPage> {
late Future<(Message, ChannelWithSubscription?, KeyToken?)>? mainFuture; late Future<(Message, ChannelPreview, KeyTokenPreview, UserPreview)>? mainFuture;
(Message, ChannelWithSubscription?, KeyToken?)? mainFutureSnapshot = null; (Message, 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;
@override @override
void initState() { void initState() {
super.initState();
mainFuture = fetchData(); mainFuture = fetchData();
super.initState();
} }
Future<(Message, ChannelWithSubscription?, KeyToken?)> fetchData() async { Future<(Message, ChannelPreview, KeyTokenPreview, UserPreview)> fetchData() async {
try {
await Future.delayed(const Duration(seconds: 0), () {}); // this is annoyingly important - otherwise we call setLoadingIndeterminate directly in initStat() and get an exception....
AppBarState().setLoadingIndeterminate(true);
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.message.messageID);
ChannelWithSubscription? chn = null; final fut_chn = APIClient.getChannelPreview(acc, msg.channelID);
try { final fut_key = APIClient.getKeyTokenPreview(acc, msg.usedKeyID);
chn = await APIClient.getChannel(acc, msg.channelID); //TODO getShortChannel (?) -> no perm final fut_usr = APIClient.getUserPreview(acc, msg.senderUserID);
} on APIException catch (e) {
if (e.error == APIError.USER_AUTH_FAILED) {
chn = null;
} else {
rethrow;
}
}
KeyToken? tok = null; final chn = await fut_chn;
try { final key = await fut_key;
tok = await APIClient.getKeyToken(acc, msg.usedKeyID); //TODO getShortKeyToken (?) -> no perm final usr = await fut_usr;
} on APIException catch (e) {
if (e.error == APIError.USER_AUTH_FAILED) {
tok = null;
} else {
rethrow;
}
}
//TODO getShortUser for sender //await Future.delayed(const Duration(seconds: 10), () {});
//await Future.delayed(const Duration(seconds: 2), () {}); final r = (msg, chn, key, usr);
final r = (msg, chn, tok);
mainFutureSnapshot = r; mainFutureSnapshot = r;
return r; return r;
} finally {
AppBarState().setLoadingIndeterminate(false);
}
} }
@override @override
@ -87,16 +79,16 @@ class _MessageViewPageState extends State<MessageViewPage> {
showSearch: false, showSearch: false,
showShare: true, showShare: true,
onShare: _share, onShare: _share,
child: FutureBuilder<(Message, ChannelWithSubscription?, KeyToken?)>( child: FutureBuilder<(Message, ChannelPreview, KeyTokenPreview, UserPreview)>(
future: mainFuture, future: mainFuture,
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.hasData) { if (snapshot.hasData) {
final (msg, chn, tok) = snapshot.data!; final (msg, chn, tok, usr) = snapshot.data!;
return _buildMessageView(context, msg, chn, tok, false); 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 (!widget.message.trimmed) {
return _buildMessageView(context, widget.message, null, null, true); return _buildMessageView(context, widget.message, null, null, null);
} else { } else {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
@ -108,7 +100,7 @@ class _MessageViewPageState extends State<MessageViewPage> {
void _share() async { void _share() async {
var msg = widget.message; var msg = widget.message;
if (mainFutureSnapshot != null) { if (mainFutureSnapshot != null) {
(msg, _, _) = mainFutureSnapshot!; (msg, _, _, _) = mainFutureSnapshot!;
} }
if (msg.content != null) { if (msg.content != null) {
@ -126,7 +118,7 @@ class _MessageViewPageState extends State<MessageViewPage> {
} }
} }
Widget _buildMessageView(BuildContext context, Message message, ChannelWithSubscription? channel, KeyToken? token, bool loading) { Widget _buildMessageView(BuildContext context, Message 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(
@ -135,16 +127,16 @@ class _MessageViewPageState extends State<MessageViewPage> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
..._buildMessageHeader(context, message, channel, token, loading), ..._buildMessageHeader(context, message, channel),
SizedBox(height: 8), SizedBox(height: 8),
if (message.content != null) ..._buildMessageContent(context, message, channel, token), 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.solidSignature, 'Sender', [message.senderName!], () => {/*TODO*/}),
_buildMetaCard(context, FontAwesomeIcons.solidGearCode, 'KeyToken', [message.usedKeyID, if (token != null) token.name], () => {/*TODO*/}), _buildMetaCard(context, FontAwesomeIcons.solidGearCode, 'KeyToken', [message.usedKeyID, if (token != null) token.name], () => {/*TODO*/}),
_buildMetaCard(context, FontAwesomeIcons.solidIdCardClip, 'MessageID', [message.messageID, if (message.userMessageID != null) message.userMessageID!], null), _buildMetaCard(context, FontAwesomeIcons.solidIdCardClip, 'MessageID', [message.messageID, if (message.userMessageID != null) message.userMessageID!], null),
_buildMetaCard(context, FontAwesomeIcons.solidSnake, 'Channel', [message.channelID, channel?.channel.displayName ?? message.channelInternalName], () => {/*TODO*/}), _buildMetaCard(context, FontAwesomeIcons.solidSnake, 'Channel', [message.channelID, channel?.displayName ?? message.channelInternalName], () => {/*TODO*/}),
_buildMetaCard(context, FontAwesomeIcons.solidTimer, 'Timestamp', [message.timestamp], null), _buildMetaCard(context, FontAwesomeIcons.solidTimer, 'Timestamp', [message.timestamp], null),
_buildMetaCard(context, FontAwesomeIcons.solidUser, 'User', ['TODO'], () => {/*TODO*/}), //TODO _buildMetaCard(context, FontAwesomeIcons.solidUser, 'User', [if (user != null) user.userID, if (user?.username != null) user!.username!], () => {/*TODO*/}), //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]),
], ],
), ),
@ -152,11 +144,11 @@ class _MessageViewPageState extends State<MessageViewPage> {
); );
} }
String _resolveChannelName(ChannelWithSubscription? channel, Message message) { String _resolveChannelName(ChannelPreview? channel, Message message) {
return channel?.channel.displayName ?? message.channelInternalName; return channel?.displayName ?? message.channelInternalName;
} }
List<Widget> _buildMessageHeader(BuildContext context, Message message, ChannelWithSubscription? channel, KeyToken? token, bool loading) { List<Widget> _buildMessageHeader(BuildContext context, Message message, ChannelPreview? channel) {
return [ return [
Row( Row(
children: [ children: [
@ -171,28 +163,11 @@ class _MessageViewPageState extends State<MessageViewPage> {
], ],
), ),
SizedBox(height: 8), SizedBox(height: 8),
if (!loading) Text(message.title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), Text(_preformatTitle(message), style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
if (loading)
Stack(
children: [
Row(
children: [
Flexible(child: Text(message.title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold))),
SizedBox(height: 20, width: 20),
],
),
Row(
children: [
Expanded(child: SizedBox(width: 0)),
SizedBox(child: CircularProgressIndicator(), height: 20, width: 20),
],
),
],
),
]; ];
} }
List<Widget> _buildMessageContent(BuildContext context, Message message, ChannelWithSubscription? channel, KeyToken? token) { List<Widget> _buildMessageContent(BuildContext context, Message message) {
return [ return [
Row( Row(
children: [ children: [
@ -273,4 +248,8 @@ class _MessageViewPageState extends State<MessageViewPage> {
); );
} }
} }
String _preformatTitle(Message message) {
return message.title.replaceAll('\n', '').replaceAll('\r', '').replaceAll('\t', ' ');
}
} }

View File

@ -6,7 +6,7 @@ import 'package:url_launcher/url_launcher.dart';
import 'package:simplecloudnotifier/state/app_auth.dart'; import 'package:simplecloudnotifier/state/app_auth.dart';
class SendRootPage extends StatefulWidget { class SendRootPage extends StatefulWidget {
const SendRootPage({super.key}); const SendRootPage({super.key, required bool isVisiblePage});
@override @override
State<SendRootPage> createState() => _SendRootPageState(); State<SendRootPage> createState() => _SendRootPageState();
@ -130,6 +130,8 @@ class _SendRootPageState extends State<SendRootPage> {
try { try {
final Uri uri = Uri.parse(url); final Uri uri = Uri.parse(url);
ApplicationLog.debug('Opening URL: [ ${uri.toString()} ]');
if (await canLaunchUrl(uri)) { if (await canLaunchUrl(uri)) {
await launchUrl(uri); await launchUrl(uri);
} else { } else {

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class SettingsRootPage extends StatefulWidget { class SettingsRootPage extends StatefulWidget {
const SettingsRootPage({super.key}); const SettingsRootPage({super.key, required bool isVisiblePage});
@override @override
State<SettingsRootPage> createState() => _SettingsRootPageState(); State<SettingsRootPage> createState() => _SettingsRootPageState();

View File

@ -14,6 +14,7 @@ class AppAuth extends ChangeNotifier implements TokenSource {
User? _user; User? _user;
Client? _client; Client? _client;
DateTime? _clientQueryTime;
String? get userID => _userID; String? get userID => _userID;
String? get tokenAdmin => _tokenAdmin; String? get tokenAdmin => _tokenAdmin;
@ -117,14 +118,17 @@ class AppAuth extends ChangeNotifier implements TokenSource {
final user = await APIClient.getUser(this, _userID!); final user = await APIClient.getUser(this, _userID!);
_user = user; _user = user;
notifyListeners();
await save(); await save();
return user; return user;
} }
Future<Client?> loadClient({bool force = false}) async { Future<Client?> loadClient({bool force = false, Duration? forceIfOlder = null}) async {
if (forceIfOlder != null && _clientQueryTime != null && _clientQueryTime!.difference(DateTime.now()) > forceIfOlder) {
force = true;
}
if (!force && _client != null && _client!.clientID == _clientID) { if (!force && _client != null && _client!.clientID == _clientID) {
return _client!; return _client!;
} }
@ -137,14 +141,12 @@ class AppAuth extends ChangeNotifier implements TokenSource {
final client = await APIClient.getClient(this, _clientID!); final client = await APIClient.getClient(this, _clientID!);
_client = client; _client = client;
notifyListeners();
await save(); await save();
return client; return client;
} on APIException catch (_) { } on APIException catch (_) {
_client = null; _client = null;
notifyListeners();
return null; return null;
} catch (exc) { } catch (exc) {
_client = null; _client = null;

View File

@ -0,0 +1,20 @@
import 'package:flutter/foundation.dart';
class AppBarState extends ChangeNotifier {
static AppBarState? _singleton = AppBarState._internal();
factory AppBarState() {
return _singleton ?? (_singleton = AppBarState._internal());
}
AppBarState._internal() {}
bool _loadingIndeterminate = false;
bool get loadingIndeterminate => _loadingIndeterminate;
void setLoadingIndeterminate(bool v) {
if (_loadingIndeterminate == v) return;
_loadingIndeterminate = v;
notifyListeners();
}
}

View File

@ -5,8 +5,10 @@ import 'package:xid/xid.dart';
part 'application_log.g.dart'; part 'application_log.g.dart';
class ApplicationLog { class ApplicationLog {
//TODO max size, auto clear old
static void debug(String message, {String? additional, StackTrace? trace}) { static void debug(String message, {String? additional, StackTrace? trace}) {
print('[DEBUG] ${message}: ${additional ?? ''}'); (additional != null && additional != '') ? print('[DEBUG] ${message}: ${additional}') : print('[DEBUG] ${message}');
Hive.box<SCNLog>('scn-logs').add(SCNLog( Hive.box<SCNLog>('scn-logs').add(SCNLog(
id: Xid().toString(), id: Xid().toString(),
@ -19,7 +21,7 @@ class ApplicationLog {
} }
static void info(String message, {String? additional, StackTrace? trace}) { static void info(String message, {String? additional, StackTrace? trace}) {
print('[INFO] ${message}: ${additional ?? ''}'); (additional != null && additional != '') ? print('[INFO] ${message}: ${additional}') : print('[INFO] ${message}');
Hive.box<SCNLog>('scn-logs').add(SCNLog( Hive.box<SCNLog>('scn-logs').add(SCNLog(
id: Xid().toString(), id: Xid().toString(),
@ -32,7 +34,7 @@ class ApplicationLog {
} }
static void warn(String message, {String? additional, StackTrace? trace}) { static void warn(String message, {String? additional, StackTrace? trace}) {
print('[WARN] ${message}: ${additional ?? ''}'); (additional != null && additional != '') ? print('[WARN] ${message}: ${additional}') : print('[WARN] ${message}');
Hive.box<SCNLog>('scn-logs').add(SCNLog( Hive.box<SCNLog>('scn-logs').add(SCNLog(
id: Xid().toString(), id: Xid().toString(),
@ -45,7 +47,7 @@ class ApplicationLog {
} }
static void error(String message, {String? additional, StackTrace? trace}) { static void error(String message, {String? additional, StackTrace? trace}) {
print('[ERROR] ${message}: ${additional ?? ''}'); (additional != null && additional != '') ? print('[ERROR] ${message}: ${additional}') : print('[ERROR] ${message}');
Hive.box<SCNLog>('scn-logs').add(SCNLog( Hive.box<SCNLog>('scn-logs').add(SCNLog(
id: Xid().toString(), id: Xid().toString(),
@ -58,7 +60,7 @@ class ApplicationLog {
} }
static void fatal(String message, {String? additional, StackTrace? trace}) { static void fatal(String message, {String? additional, StackTrace? trace}) {
print('[FATAL] ${message}: ${additional ?? ''}'); (additional != null && additional != '') ? print('[FATAL] ${message}: ${additional}') : print('[FATAL] ${message}');
Hive.box<SCNLog>('scn-logs').add(SCNLog( Hive.box<SCNLog>('scn-logs').add(SCNLog(
id: Xid().toString(), id: Xid().toString(),

View File

@ -0,0 +1,236 @@
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:simplecloudnotifier/state/interfaces.dart';
part 'fb_message.g.dart';
class FBMessageLog {
//TODO max size, auto clear old
static void insert(RemoteMessage msg) {
Hive.box<FBMessage>('scn-fb-messages').add(FBMessage.fromRemoteMessage(msg));
}
}
@HiveType(typeId: 106)
class FBMessage extends HiveObject implements FieldDebuggable {
@HiveField(0)
final String? senderId;
@HiveField(1)
final String? category;
@HiveField(2)
final String? collapseKey;
@HiveField(3)
final bool contentAvailable;
@HiveField(4)
final Map<String, String> data;
@HiveField(5)
final String? from;
@HiveField(6)
final String? messageId;
@HiveField(7)
final String? messageType;
@HiveField(8)
final bool mutableContent;
@HiveField(9)
final RemoteNotification? notification;
@HiveField(10)
final DateTime? sentTime;
@HiveField(11)
final String? threadId;
@HiveField(12)
final int? ttl;
@HiveField(20)
final String? notificationAndroidChannelId;
@HiveField(21)
final String? notificationAndroidClickAction;
@HiveField(22)
final String? notificationAndroidColor;
@HiveField(23)
final int? notificationAndroidCount;
@HiveField(24)
final String? notificationAndroidImageUrl;
@HiveField(25)
final String? notificationAndroidLink;
@HiveField(26)
final AndroidNotificationPriority? notificationAndroidPriority;
@HiveField(27)
final String? notificationAndroidSmallIcon;
@HiveField(28)
final String? notificationAndroidSound;
@HiveField(29)
final String? notificationAndroidTicker;
@HiveField(30)
final AndroidNotificationVisibility? notificationAndroidVisibility;
@HiveField(31)
final String? notificationAndroidTag;
@HiveField(40)
final String? notificationAppleBadge;
@HiveField(41)
final AppleNotificationSound? notificationAppleSound;
@HiveField(42)
final String? notificationAppleImageUrl;
@HiveField(43)
final String? notificationAppleSubtitle;
@HiveField(44)
final List<String>? notificationAppleSubtitleLocArgs;
@HiveField(45)
final String? notificationAppleSubtitleLocKey;
@HiveField(50)
final String? notificationWebAnalyticsLabel;
@HiveField(51)
final String? notificationWebImage;
@HiveField(52)
final String? notificationWebLink;
@HiveField(60)
final String? notificationTitle;
@HiveField(61)
final List<String>? notificationTitleLocArgs;
@HiveField(62)
final String? notificationTitleLocKey;
@HiveField(63)
final String? notificationBody;
@HiveField(64)
final List<String>? notificationBodyLocArgs;
@HiveField(65)
final String? notificationBodyLocKey;
FBMessage({
required this.senderId,
required this.category,
required this.collapseKey,
required this.contentAvailable,
required this.data,
required this.from,
required this.messageId,
required this.messageType,
required this.mutableContent,
required this.notification,
required this.sentTime,
required this.threadId,
required this.ttl,
required this.notificationAndroidChannelId,
required this.notificationAndroidClickAction,
required this.notificationAndroidColor,
required this.notificationAndroidCount,
required this.notificationAndroidImageUrl,
required this.notificationAndroidLink,
required this.notificationAndroidPriority,
required this.notificationAndroidSmallIcon,
required this.notificationAndroidSound,
required this.notificationAndroidTicker,
required this.notificationAndroidVisibility,
required this.notificationAndroidTag,
required this.notificationAppleBadge,
required this.notificationAppleSound,
required this.notificationAppleImageUrl,
required this.notificationAppleSubtitle,
required this.notificationAppleSubtitleLocArgs,
required this.notificationAppleSubtitleLocKey,
required this.notificationWebAnalyticsLabel,
required this.notificationWebImage,
required this.notificationWebLink,
required this.notificationTitle,
required this.notificationTitleLocArgs,
required this.notificationTitleLocKey,
required this.notificationBody,
required this.notificationBodyLocArgs,
required this.notificationBodyLocKey,
});
FBMessage.fromRemoteMessage(RemoteMessage rmsg)
: this.senderId = rmsg.senderId,
this.category = rmsg.category,
this.collapseKey = rmsg.collapseKey,
this.contentAvailable = rmsg.contentAvailable,
this.data = rmsg.data.map((key, value) => MapEntry(key, value?.toString() ?? '')),
this.from = rmsg.from,
this.messageId = rmsg.messageId,
this.messageType = rmsg.messageType,
this.mutableContent = rmsg.mutableContent,
this.notification = rmsg.notification,
this.sentTime = rmsg.sentTime,
this.threadId = rmsg.threadId,
this.ttl = rmsg.ttl,
this.notificationAndroidChannelId = rmsg.notification?.android?.channelId,
this.notificationAndroidClickAction = rmsg.notification?.android?.clickAction,
this.notificationAndroidColor = rmsg.notification?.android?.color,
this.notificationAndroidCount = rmsg.notification?.android?.count,
this.notificationAndroidImageUrl = rmsg.notification?.android?.imageUrl,
this.notificationAndroidLink = rmsg.notification?.android?.link,
this.notificationAndroidPriority = rmsg.notification?.android?.priority,
this.notificationAndroidSmallIcon = rmsg.notification?.android?.smallIcon,
this.notificationAndroidSound = rmsg.notification?.android?.sound,
this.notificationAndroidTicker = rmsg.notification?.android?.ticker,
this.notificationAndroidVisibility = rmsg.notification?.android?.visibility,
this.notificationAndroidTag = rmsg.notification?.android?.tag,
this.notificationAppleBadge = rmsg.notification?.apple?.badge,
this.notificationAppleSound = rmsg.notification?.apple?.sound,
this.notificationAppleImageUrl = rmsg.notification?.apple?.imageUrl,
this.notificationAppleSubtitle = rmsg.notification?.apple?.subtitle,
this.notificationAppleSubtitleLocArgs = rmsg.notification?.apple?.subtitleLocArgs,
this.notificationAppleSubtitleLocKey = rmsg.notification?.apple?.subtitleLocKey,
this.notificationWebAnalyticsLabel = rmsg.notification?.web?.analyticsLabel,
this.notificationWebImage = rmsg.notification?.web?.image,
this.notificationWebLink = rmsg.notification?.web?.link,
this.notificationTitle = rmsg.notification?.title,
this.notificationTitleLocArgs = rmsg.notification?.titleLocArgs,
this.notificationTitleLocKey = rmsg.notification?.titleLocKey,
this.notificationBody = rmsg.notification?.body,
this.notificationBodyLocArgs = rmsg.notification?.bodyLocArgs,
this.notificationBodyLocKey = rmsg.notification?.bodyLocKey {}
@override
String toString() {
return 'FBMessage[${this.messageId ?? 'NULL'}]';
}
List<(String, String)> debugFieldList() {
return [
('senderId', this.senderId ?? ''),
('category', this.category ?? ''),
('collapseKey', this.collapseKey ?? ''),
('contentAvailable', this.contentAvailable.toString()),
('data', this.data.toString()),
('from', this.from ?? ''),
('messageId', this.messageId ?? ''),
('messageType', this.messageType ?? ''),
('mutableContent', this.mutableContent.toString()),
('notification', this.notification?.toString() ?? ''),
('sentTime', this.sentTime?.toString() ?? ''),
('threadId', this.threadId ?? ''),
('ttl', this.ttl?.toString() ?? ''),
('notification.Android.ChannelId', this.notificationAndroidChannelId ?? ''),
('notification.Android.ClickAction', this.notificationAndroidClickAction ?? ''),
('notification.Android.Color', this.notificationAndroidColor ?? ''),
('notification.Android.Count', this.notificationAndroidCount?.toString() ?? ''),
('notification.Android.ImageUrl', this.notificationAndroidImageUrl ?? ''),
('notification.Android.Link', this.notificationAndroidLink ?? ''),
('notification.Android.Priority', this.notificationAndroidPriority?.toString() ?? ''),
('notification.Android.SmallIcon', this.notificationAndroidSmallIcon ?? ''),
('notification.Android.Sound', this.notificationAndroidSound ?? ''),
('notification.Android.Ticker', this.notificationAndroidTicker ?? ''),
('notification.Android.Visibility', this.notificationAndroidVisibility?.toString() ?? ''),
('notification.Android.Tag', this.notificationAndroidTag ?? ''),
('notification.Apple.Badge', this.notificationAppleBadge ?? ''),
('notification.Apple.Sound', this.notificationAppleSound?.toString() ?? ''),
('notification.Apple.ImageUrl', this.notificationAppleImageUrl ?? ''),
('notification.Apple.Subtitle', this.notificationAppleSubtitle ?? ''),
('notification.Apple.SubtitleLocArgs', this.notificationAppleSubtitleLocArgs?.toString() ?? ''),
('notification.Apple.SubtitleLocKey', this.notificationAppleSubtitleLocKey ?? ''),
('notification.Web.AnalyticsLabel', this.notificationWebAnalyticsLabel ?? ''),
('notification.Web.Image', this.notificationWebImage ?? ''),
('notification.Web.Link', this.notificationWebLink ?? ''),
('notification.Title', this.notificationTitle ?? ''),
('notification.TitleLocArgs', this.notificationTitleLocArgs?.toString() ?? ''),
('notification.TitleLocKey', this.notificationTitleLocKey ?? ''),
('notification.Body', this.notificationBody ?? ''),
('notification.BodyLocArgs', this.notificationBodyLocArgs?.toString() ?? ''),
('notification.BodyLocKey', this.notificationBodyLocKey ?? ''),
];
}
}

View File

@ -0,0 +1,159 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'fb_message.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class FBMessageAdapter extends TypeAdapter<FBMessage> {
@override
final int typeId = 106;
@override
FBMessage read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return FBMessage(
senderId: fields[0] as String?,
category: fields[1] as String?,
collapseKey: fields[2] as String?,
contentAvailable: fields[3] as bool,
data: (fields[4] as Map).cast<String, String>(),
from: fields[5] as String?,
messageId: fields[6] as String?,
messageType: fields[7] as String?,
mutableContent: fields[8] as bool,
notification: fields[9] as RemoteNotification?,
sentTime: fields[10] as DateTime?,
threadId: fields[11] as String?,
ttl: fields[12] as int?,
notificationAndroidChannelId: fields[20] as String?,
notificationAndroidClickAction: fields[21] as String?,
notificationAndroidColor: fields[22] as String?,
notificationAndroidCount: fields[23] as int?,
notificationAndroidImageUrl: fields[24] as String?,
notificationAndroidLink: fields[25] as String?,
notificationAndroidPriority: fields[26] as AndroidNotificationPriority?,
notificationAndroidSmallIcon: fields[27] as String?,
notificationAndroidSound: fields[28] as String?,
notificationAndroidTicker: fields[29] as String?,
notificationAndroidVisibility:
fields[30] as AndroidNotificationVisibility?,
notificationAndroidTag: fields[31] as String?,
notificationAppleBadge: fields[40] as String?,
notificationAppleSound: fields[41] as AppleNotificationSound?,
notificationAppleImageUrl: fields[42] as String?,
notificationAppleSubtitle: fields[43] as String?,
notificationAppleSubtitleLocArgs: (fields[44] as List?)?.cast<String>(),
notificationAppleSubtitleLocKey: fields[45] as String?,
notificationWebAnalyticsLabel: fields[50] as String?,
notificationWebImage: fields[51] as String?,
notificationWebLink: fields[52] as String?,
notificationTitle: fields[60] as String?,
notificationTitleLocArgs: (fields[61] as List?)?.cast<String>(),
notificationTitleLocKey: fields[62] as String?,
notificationBody: fields[63] as String?,
notificationBodyLocArgs: (fields[64] as List?)?.cast<String>(),
notificationBodyLocKey: fields[65] as String?,
);
}
@override
void write(BinaryWriter writer, FBMessage obj) {
writer
..writeByte(40)
..writeByte(0)
..write(obj.senderId)
..writeByte(1)
..write(obj.category)
..writeByte(2)
..write(obj.collapseKey)
..writeByte(3)
..write(obj.contentAvailable)
..writeByte(4)
..write(obj.data)
..writeByte(5)
..write(obj.from)
..writeByte(6)
..write(obj.messageId)
..writeByte(7)
..write(obj.messageType)
..writeByte(8)
..write(obj.mutableContent)
..writeByte(9)
..write(obj.notification)
..writeByte(10)
..write(obj.sentTime)
..writeByte(11)
..write(obj.threadId)
..writeByte(12)
..write(obj.ttl)
..writeByte(20)
..write(obj.notificationAndroidChannelId)
..writeByte(21)
..write(obj.notificationAndroidClickAction)
..writeByte(22)
..write(obj.notificationAndroidColor)
..writeByte(23)
..write(obj.notificationAndroidCount)
..writeByte(24)
..write(obj.notificationAndroidImageUrl)
..writeByte(25)
..write(obj.notificationAndroidLink)
..writeByte(26)
..write(obj.notificationAndroidPriority)
..writeByte(27)
..write(obj.notificationAndroidSmallIcon)
..writeByte(28)
..write(obj.notificationAndroidSound)
..writeByte(29)
..write(obj.notificationAndroidTicker)
..writeByte(30)
..write(obj.notificationAndroidVisibility)
..writeByte(31)
..write(obj.notificationAndroidTag)
..writeByte(40)
..write(obj.notificationAppleBadge)
..writeByte(41)
..write(obj.notificationAppleSound)
..writeByte(42)
..write(obj.notificationAppleImageUrl)
..writeByte(43)
..write(obj.notificationAppleSubtitle)
..writeByte(44)
..write(obj.notificationAppleSubtitleLocArgs)
..writeByte(45)
..write(obj.notificationAppleSubtitleLocKey)
..writeByte(50)
..write(obj.notificationWebAnalyticsLabel)
..writeByte(51)
..write(obj.notificationWebImage)
..writeByte(52)
..write(obj.notificationWebLink)
..writeByte(60)
..write(obj.notificationTitle)
..writeByte(61)
..write(obj.notificationTitleLocArgs)
..writeByte(62)
..write(obj.notificationTitleLocKey)
..writeByte(63)
..write(obj.notificationBody)
..writeByte(64)
..write(obj.notificationBodyLocArgs)
..writeByte(65)
..write(obj.notificationBodyLocKey);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is FBMessageAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@ -6,6 +6,8 @@ import 'package:xid/xid.dart';
part 'request_log.g.dart'; part 'request_log.g.dart';
class RequestLog { class RequestLog {
//TODO max size, auto clear old
static void addRequestException(String name, DateTime tStart, String method, Uri uri, String reqbody, Map<String, String> reqheaders, dynamic e, StackTrace trace) { static void addRequestException(String name, DateTime tStart, String method, Uri uri, String reqbody, Map<String, String> reqheaders, dynamic e, StackTrace trace) {
Hive.box<SCNRequest>('scn-requests').add(SCNRequest( Hive.box<SCNRequest>('scn-requests').add(SCNRequest(
id: Xid().toString(), id: Xid().toString(),

View File

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

View File

@ -1 +1,52 @@
class Navi {} import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:simplecloudnotifier/state/app_bar_state.dart';
class Navi {
static final SCNRouteObserver routeObserver = SCNRouteObserver();
static final RouteObserver<ModalRoute<void>> modalRouteObserver = RouteObserver<ModalRoute<void>>();
static void push<T extends Widget>(BuildContext context, T Function() builder) {
Provider.of<AppBarState>(context, listen: false).setLoadingIndeterminate(false);
Navigator.push(context, MaterialPageRoute<T>(builder: (context) => builder()));
}
static void popToRoot(BuildContext context) {
Provider.of<AppBarState>(context, listen: false).setLoadingIndeterminate(false);
Navigator.popUntil(context, (route) => route.isFirst);
}
}
class SCNRouteObserver extends RouteObserver<PageRoute<dynamic>> {
@override
void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
super.didPush(route, previousRoute);
if (route is PageRoute) {
AppBarState().setLoadingIndeterminate(false);
print('[SCNRouteObserver] .didPush()');
}
}
@override
void didReplace({Route<dynamic>? newRoute, Route<dynamic>? oldRoute}) {
super.didReplace(newRoute: newRoute, oldRoute: oldRoute);
if (newRoute is PageRoute) {
AppBarState().setLoadingIndeterminate(false);
print('[SCNRouteObserver] .didReplace()');
}
}
@override
void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) {
super.didPop(route, previousRoute);
if (previousRoute is PageRoute && route is PageRoute) {
AppBarState().setLoadingIndeterminate(false);
print('[SCNRouteObserver] .didPop()');
}
}
}

View File

@ -1,4 +1,8 @@
{ {
"info": {
"version": 1,
"author": "xcode"
},
"images": [ "images": [
{ {
"size": "16x16", "size": "16x16",
@ -60,9 +64,5 @@
"filename": "app_icon_1024.png", "filename": "app_icon_1024.png",
"scale": "2x" "scale": "2x"
} }
], ]
"info" : {
"version" : 1,
"author" : "xcode"
}
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 520 B

After

Width:  |  Height:  |  Size: 727 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

@ -25,6 +25,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.4.1" version: "6.4.1"
archive:
dependency: transitive
description:
name: archive
sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d
url: "https://pub.dev"
source: hosted
version: "3.6.1"
args: args:
dependency: transitive dependency: transitive
description: description:
@ -129,6 +137,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.3" version: "2.0.3"
cli_util:
dependency: transitive
description:
name: cli_util
sha256: c05b7406fdabc7a49a3929d4af76bcaccbbffcbcdcf185b082e1ae07da323d19
url: "https://pub.dev"
source: hosted
version: "0.4.1"
clock: clock:
dependency: transitive dependency: transitive
description: description:
@ -253,18 +269,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: firebase_core_platform_interface name: firebase_core_platform_interface
sha256: c437ae5d17e6b5cc7981cf6fd458a5db4d12979905f9aafd1fea930428a9fe63 sha256: "1003a5a03a61fc9a22ef49f37cbcb9e46c86313a7b2e7029b9390cf8c6fc32cb"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.0.0" version: "5.1.0"
firebase_core_web: firebase_core_web:
dependency: transitive dependency: transitive
description: description:
name: firebase_core_web name: firebase_core_web
sha256: "43d9e951ac52b87ae9cc38ecdcca1e8fa7b52a1dd26a96085ba41ce5108db8e9" sha256: "6643fe3dbd021e6ccfb751f7882b39df355708afbdeb4130fc50f9305a9d1a3d"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.17.0" version: "2.17.2"
firebase_messaging: firebase_messaging:
dependency: "direct main" dependency: "direct main"
description: description:
@ -302,6 +318,14 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_launcher_icons:
dependency: "direct main"
description:
name: flutter_launcher_icons
sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea"
url: "https://pub.dev"
source: hosted
version: "0.13.1"
flutter_lazy_indexed_stack: flutter_lazy_indexed_stack:
dependency: "direct main" dependency: "direct main"
description: description:
@ -423,6 +447,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.0" version: "1.0.0"
image:
dependency: transitive
description:
name: image
sha256: "2237616a36c0d69aef7549ab439b833fb7f9fb9fc861af2cc9ac3eedddd69ca8"
url: "https://pub.dev"
source: hosted
version: "4.2.0"
infinite_scroll_pagination: infinite_scroll_pagination:
dependency: "direct main" dependency: "direct main"
description: description:
@ -631,14 +663,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.0+3" version: "3.1.0+3"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27
url: "https://pub.dev"
source: hosted
version: "6.0.2"
platform: platform:
dependency: transitive dependency: transitive
description: description:
name: platform name: platform
sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec" sha256: "9b71283fc13df574056616011fb138fd3b793ea47cc509c189a6c3fa5f8a1a65"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.4" version: "3.1.5"
plugin_platform_interface: plugin_platform_interface:
dependency: transitive dependency: transitive
description: description:
@ -675,10 +715,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: pubspec_parse name: pubspec_parse
sha256: c63b2876e58e194e4b0828fcb080ad0e06d051cb607a6be51a9e084f47cb9367 sha256: c799b721d79eb6ee6fa56f00c04b472dcd44a30d258fac2174a6ec57302678f8
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.2.3" version: "1.3.0"
qr: qr:
dependency: transitive dependency: transitive
description: description:
@ -904,10 +944,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: url_launcher name: url_launcher
sha256: "6ce1e04375be4eed30548f10a315826fd933c1e493206eab82eed01f438c8d2e" sha256: "21b704ce5fa560ea9f3b525b43601c678728ba46725bab9b01187b4831377ed3"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.2.6" version: "6.3.0"
url_launcher_android: url_launcher_android:
dependency: transitive dependency: transitive
description: description:
@ -1044,6 +1084,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.2.1" version: "1.2.1"
xml:
dependency: transitive
description:
name: xml
sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226
url: "https://pub.dev"
source: hosted
version: "6.5.0"
yaml: yaml:
dependency: transitive dependency: transitive
description: description:

View File

@ -1,5 +1,5 @@
name: simplecloudnotifier name: simplecloudnotifier
description: "A new Flutter project." description: "Receive push messages"
publish_to: 'none' publish_to: 'none'
version: 2.0.0+100 version: 2.0.0+100
@ -11,6 +11,8 @@ dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
flutter_launcher_icons: "^0.13.1"
font_awesome_flutter: '>= 4.7.0' font_awesome_flutter: '>= 4.7.0'
cupertino_icons: ^1.0.2 cupertino_icons: ^1.0.2
http: ^1.2.0 http: ^1.2.0

Binary file not shown.

Before

Width:  |  Height:  |  Size: 917 B

After

Width:  |  Height:  |  Size: 727 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 71 KiB

View File

@ -3,8 +3,8 @@
"short_name": "simplecloudnotifier", "short_name": "simplecloudnotifier",
"start_url": ".", "start_url": ".",
"display": "standalone", "display": "standalone",
"background_color": "#0175C2", "background_color": "#hexcode",
"theme_color": "#0175C2", "theme_color": "#hexcode",
"description": "A new Flutter project.", "description": "A new Flutter project.",
"orientation": "portrait-primary", "orientation": "portrait-primary",
"prefer_related_applications": false, "prefer_related_applications": false,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@ -11,8 +11,10 @@ import (
"fmt" "fmt"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"io" "io"
"net/http" "net/http"
"strconv"
"strings" "strings"
"time" "time"
) )
@ -55,30 +57,43 @@ func (fb FirebaseConnector) SendNotification(ctx context.Context, user models.Us
uri := "https://fcm.googleapis.com/v1/projects/" + fb.fbProject + "/messages:send" uri := "https://fcm.googleapis.com/v1/projects/" + fb.fbProject + "/messages:send"
jsonBody := gin.H{ jsonBody := gin.H{}
"token": client.FCMToken,
}
if client.Type == models.ClientTypeIOS { if client.Type == models.ClientTypeIOS {
jsonBody["notification"] = gin.H{ jsonBody = gin.H{
"token": client.FCMToken,
"notification": gin.H{
"title": msg.Title, "title": msg.Title,
"body": msg.ShortContent(), "body": msg.ShortContent(),
},
"apns": gin.H{},
} }
jsonBody["apns"] = gin.H{}
} else if client.Type == models.ClientTypeAndroid { } else if client.Type == models.ClientTypeAndroid {
jsonBody["android"] = gin.H{ jsonBody = gin.H{
"token": client.FCMToken,
"android": gin.H{
"priority": "high", "priority": "high",
"fcm_options": gin.H{},
},
"data": gin.H{
"scn_msg_id": msg.MessageID.String(),
"usr_msg_id": langext.Coalesce(msg.UserMessageID, ""),
"client_id": client.ClientID.String(),
"timestamp": strconv.FormatInt(msg.Timestamp().Unix(), 10),
"priority": strconv.Itoa(msg.Priority),
"trimmed": langext.Conditional(msg.NeedsTrim(), "true", "false"),
"title": msg.Title,
"channel": channel.DisplayName,
"body": langext.Coalesce(msg.TrimmedContent(), ""),
},
}
} else {
jsonBody = gin.H{
"token": client.FCMToken,
"notification": gin.H{ "notification": gin.H{
"event_time": msg.Timestamp().Format(time.RFC3339),
"title": msg.FormatNotificationTitle(user, channel), "title": msg.FormatNotificationTitle(user, channel),
"body": msg.ShortContent(), "body": msg.ShortContent(),
}, },
"fcm_options": gin.H{},
}
} else {
jsonBody["notification"] = gin.H{
"title": msg.FormatNotificationTitle(user, channel),
"body": msg.ShortContent(),
} }
} }