feat(client): FCM web push registration
web/index.html loads the Firebase compat SDK and defines window.kcGetPushToken() — inits Firebase from window.KC_FIREBASE, asks for notification permission, registers the service worker and returns an FCM token (or null if not configured / denied). web/firebase-messaging-sw.js shows background notifications. browser_web.dart exposes getPushToken() over that JS function (stub returns null off-web). After every successful login AppState fires _registerForPush() -> POST /api/push/register, best-effort. Config placeholders carry the known values (projectId konfi-castle-app, messagingSenderId 307226979593); apiKey / appId / vapidKey still say REPLACE_ME, so push stays inert until they're filled in — the app runs either way. flutter analyze/test/build web green. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import 'browser.dart' as browser;
|
||||||
import 'oidc.dart';
|
import 'oidc.dart';
|
||||||
|
|
||||||
/// Backend base URL. Override at build/run time with
|
/// Backend base URL. Override at build/run time with
|
||||||
@@ -662,6 +663,10 @@ class Api {
|
|||||||
'gemeindeId': gemeindeId,
|
'gemeindeId': gemeindeId,
|
||||||
}) as Map<String, dynamic>;
|
}) as Map<String, dynamic>;
|
||||||
|
|
||||||
|
// --- push ---
|
||||||
|
Future<void> registerDevice(String token, {String platform = 'web'}) =>
|
||||||
|
_post('/push/register', {'token': token, 'platform': platform});
|
||||||
|
|
||||||
// --- chat: REST for channels/history; live send/receive is the /chat WS ---
|
// --- chat: REST for channels/history; live send/receive is the /chat WS ---
|
||||||
Future<List<ChatChannel>> channels(String kcId) async {
|
Future<List<ChatChannel>> channels(String kcId) async {
|
||||||
final list = await _get('/chat/$kcId/channels') as List<dynamic>;
|
final list = await _get('/chat/$kcId/channels') as List<dynamic>;
|
||||||
@@ -746,6 +751,16 @@ class AppState extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
_authError = null;
|
_authError = null;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
_registerForPush(); // best-effort, fire and forget
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _registerForPush() async {
|
||||||
|
try {
|
||||||
|
final pushToken = await browser.getPushToken();
|
||||||
|
if (pushToken != null) await _api.registerDevice(pushToken);
|
||||||
|
} catch (_) {
|
||||||
|
// push is optional
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _clear(SharedPreferences prefs) async {
|
Future<void> _clear(SharedPreferences prefs) async {
|
||||||
|
|||||||
@@ -12,3 +12,6 @@ Future<({String name, List<int> bytes})?> pickFile() async =>
|
|||||||
throw UnsupportedError(_msg);
|
throw UnsupportedError(_msg);
|
||||||
void downloadText(String filename, String content, {String mime = 'text/plain'}) =>
|
void downloadText(String filename, String content, {String mime = 'text/plain'}) =>
|
||||||
throw UnsupportedError(_msg);
|
throw UnsupportedError(_msg);
|
||||||
|
|
||||||
|
/// No push on non-web platforms in this build.
|
||||||
|
Future<String?> getPushToken() async => null;
|
||||||
|
|||||||
@@ -3,6 +3,20 @@ import 'dart:js_interop';
|
|||||||
|
|
||||||
import 'package:web/web.dart' as web;
|
import 'package:web/web.dart' as web;
|
||||||
|
|
||||||
|
/// Provided by the inline Firebase bootstrap in web/index.html. Returns an FCM
|
||||||
|
/// registration token, or null if push isn't configured / permission denied.
|
||||||
|
@JS('kcGetPushToken')
|
||||||
|
external JSPromise<JSString?> _kcGetPushToken();
|
||||||
|
|
||||||
|
Future<String?> getPushToken() async {
|
||||||
|
try {
|
||||||
|
final result = await _kcGetPushToken().toDart;
|
||||||
|
return result?.toDart;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Per-tab storage for the PKCE verifier + CSRF state (cleared when the tab
|
/// Per-tab storage for the PKCE verifier + CSRF state (cleared when the tab
|
||||||
/// closes), and the little bit of `window` access the OIDC redirect needs.
|
/// closes), and the little bit of `window` access the OIDC redirect needs.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// Background handler for FCM web push. Keep the config in sync with
|
||||||
|
// window.KC_FIREBASE in index.html (a service worker can't read window).
|
||||||
|
// Fill in apiKey / appId from the Firebase console before enabling push.
|
||||||
|
importScripts('https://www.gstatic.com/firebasejs/10.13.2/firebase-app-compat.js');
|
||||||
|
importScripts('https://www.gstatic.com/firebasejs/10.13.2/firebase-messaging-compat.js');
|
||||||
|
|
||||||
|
firebase.initializeApp({
|
||||||
|
apiKey: 'REPLACE_ME',
|
||||||
|
projectId: 'konfi-castle-app',
|
||||||
|
messagingSenderId: '307226979593',
|
||||||
|
appId: 'REPLACE_ME',
|
||||||
|
});
|
||||||
|
|
||||||
|
firebase.messaging().onBackgroundMessage(function (payload) {
|
||||||
|
const n = payload.notification || {};
|
||||||
|
self.registration.showNotification(n.title || 'KC-App', {
|
||||||
|
body: n.body || '',
|
||||||
|
icon: '/icons/Icon-192.png',
|
||||||
|
data: payload.data || {},
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -31,6 +31,39 @@
|
|||||||
|
|
||||||
<title>KC-App</title>
|
<title>KC-App</title>
|
||||||
<link rel="manifest" href="manifest.json">
|
<link rel="manifest" href="manifest.json">
|
||||||
|
|
||||||
|
<!-- Firebase Cloud Messaging (web push). Fill in apiKey / appId / vapidKey
|
||||||
|
from the Firebase console (Project settings > General / Cloud Messaging).
|
||||||
|
Until then window.KC_FIREBASE.apiKey stays "REPLACE_ME" and
|
||||||
|
kcGetPushToken() returns null, so the app simply runs without push. -->
|
||||||
|
<script src="https://www.gstatic.com/firebasejs/10.13.2/firebase-app-compat.js"></script>
|
||||||
|
<script src="https://www.gstatic.com/firebasejs/10.13.2/firebase-messaging-compat.js"></script>
|
||||||
|
<script>
|
||||||
|
window.KC_FIREBASE = {
|
||||||
|
apiKey: "REPLACE_ME",
|
||||||
|
authDomain: "konfi-castle-app.firebaseapp.com",
|
||||||
|
projectId: "konfi-castle-app",
|
||||||
|
storageBucket: "konfi-castle-app.firebasestorage.app",
|
||||||
|
messagingSenderId: "307226979593",
|
||||||
|
appId: "REPLACE_ME",
|
||||||
|
vapidKey: "REPLACE_ME"
|
||||||
|
};
|
||||||
|
window.kcGetPushToken = async function () {
|
||||||
|
try {
|
||||||
|
var cfg = window.KC_FIREBASE;
|
||||||
|
if (!cfg.apiKey || cfg.apiKey === "REPLACE_ME" || !("Notification" in window)) return null;
|
||||||
|
if (!firebase.apps.length) firebase.initializeApp(cfg);
|
||||||
|
var permission = await Notification.requestPermission();
|
||||||
|
if (permission !== "granted") return null;
|
||||||
|
var registration = await navigator.serviceWorker.register("firebase-messaging-sw.js");
|
||||||
|
var messaging = firebase.messaging();
|
||||||
|
return await messaging.getToken({ vapidKey: cfg.vapidKey, serviceWorkerRegistration: registration });
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[push] init failed", e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!--
|
<!--
|
||||||
|
|||||||
Reference in New Issue
Block a user