Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 0 additions & 15 deletions lib/config/backend_config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,33 +15,25 @@ class BackendConfig {

static const _key = 'backend.url';

/// The hosted default (Schuly Cloud). Override per build:
/// flutter build apk --dart-define=BACKEND_BASE_URL=https://api.schuly.dev
static const hostedUrl = String.fromEnvironment(
'BACKEND_BASE_URL',
defaultValue: 'http://localhost:5033',
);

static String _url = hostedUrl;

/// Current backend base URL (no trailing slash).
static String get url => _url;

/// Whether the app is pointed at a custom (self-hosted) backend.
static bool get isCustom => _url != hostedUrl;

static Future<void> load() async {
final saved = (await SharedPreferences.getInstance()).getString(_key);
if (saved != null && saved.isNotEmpty) _url = saved;
}

/// Strips a trailing slash from a URL, returning '' for null/empty.
static String normalise(String? value) =>
(value ?? '').trim().replaceAll(RegExp(r'/+$'), '');

/// True if [value] uses plaintext `http://` to a non-loopback host, so
/// credentials and tokens would travel in the clear. Used to warn before a
/// self-hoster saves an insecure custom backend.
static bool isInsecure(String? value) {
final uri = Uri.tryParse(normalise(value));
if (uri == null || uri.scheme != 'http') return false;
Expand All @@ -53,10 +45,6 @@ class BackendConfig {
!h.endsWith('.localhost');
}

/// Probes [baseUrl] by fetching the anonymous `GET /api/app`. A reachable
/// Schuly backend returns a JSON object with a `clientId`; on success this
/// returns its reported `version` (or `'unknown'` if the field is missing).
/// Returns null on any network/parse error or a non-Schuly response.
static Future<String?> probe(String baseUrl) async {
final url = normalise(baseUrl);
if (url.isEmpty) return null;
Expand All @@ -76,9 +64,6 @@ class BackendConfig {
}
}

/// Normalises and persists [value] (trailing slash trimmed). A null/empty
/// value, or one equal to the hosted default, resets to hosted. Returns the
/// resolved URL.
static Future<String> setUrl(String? value) async {
final prefs = await SharedPreferences.getInstance();
final v = value?.trim().replaceAll(RegExp(r'/+$'), '');
Expand Down
12 changes: 0 additions & 12 deletions lib/config/oidc_config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ class OidcSettings {
this.endSessionEndpoint,
});

/// The OIDC scopes as a list, split from the space-delimited [scope] string.
List<String> get scopes => scope.split(' ').where((s) => s.isNotEmpty).toList();

/// Deep-link scheme the provider redirects back to (e.g. `schulytest`),
Expand All @@ -42,17 +41,11 @@ class OidcSettings {
}

class OidcConfig {
// Backend base URL, resolved at runtime from [BackendConfig] (hosted default
// or a self-hosted override chosen in onboarding). The build-time default
// lives in [BackendConfig.hostedUrl].
static String get backendBaseUrl => BackendConfig.url;

static OidcSettings? _settings;
static Future<OidcSettings>? _loading;

/// Loads (once) and caches the OIDC settings from the backend. Safe to call
/// from multiple places concurrently - the in-flight load is shared, and a
/// failed load is not cached so the next call retries.
static Future<OidcSettings> settings() {
final cached = _settings;
if (cached != null) return Future<OidcSettings>.value(cached);
Expand All @@ -66,8 +59,6 @@ class OidcConfig {
});
}

/// Clears the cached settings so the next [settings] call re-fetches them -
/// used after the backend URL changes at runtime (the OIDC authority differs).
static void reset() {
_settings = null;
_loading = null;
Expand Down Expand Up @@ -98,9 +89,6 @@ class OidcConfig {
return jsonDecode(r.body) as Map<String, dynamic>;
}

/// Resolves a backend-supplied URL: absolute (http…) is used as-is, a
/// root-relative path (/api/avatars/…) is prefixed with [backendBaseUrl],
/// null/empty returns null. Signed capability URLs need no auth header.
static String? resolveUrl(String? url) {
if (url == null || url.isEmpty) return null;
if (url.startsWith('http')) return url;
Expand Down
8 changes: 0 additions & 8 deletions lib/domain/my_school.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,14 @@ import 'package:schuly_api/schuly_api.dart';

import '../config/oidc_config.dart';

/// A school the signed-in user belongs to, from `GET /api/schools/my-schools`.
/// Carries the school name plus the user's identity (full name + email) at
/// that school - what the account switcher displays. [provider] is the catalog
/// system key, and [pluginBasePath] its plugin route - both discovered from the
/// backend catalog, never hardcoded.
class MySchool {
final String id;
final String name;
final String? email;
final String? fullName;
final String provider;
/// Catalog plugin base path backing this school (accounts/sync/status).
final String? pluginBasePath;
/// The plugin account id backing this school (for triggering a sync).
final String? pluginAccountId;
/// Backend-supplied, fully-resolved URLs (null if not provided).
final String? logoUrl;
final String? profilePictureUrl;

Expand Down
4 changes: 0 additions & 4 deletions lib/domain/private_data.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
// Typed responses from the backend's stateless plugin proxies
// (`/api/plugins/<plugin>/stateless/*`), used by private mode. Field names
// mirror the plugins' flat DTOs (camelCase JSON).

class PrivateRefreshResult {
final bool success;
Expand All @@ -11,7 +8,6 @@ class PrivateRefreshResult {
final String? webSessionUserId;
final String? webSessionTransId;

/// Rotated context_state as a JSON string (re-encoded from the returned object).
final String? contextState;

const PrivateRefreshResult({
Expand Down
12 changes: 0 additions & 12 deletions lib/domain/school_system.dart
Original file line number Diff line number Diff line change
@@ -1,27 +1,17 @@
/// A login provider the backend advertises via `GET /api/app/school-systems`.
/// The app renders the picker (and, later, the login form) from this instead of
/// hardcoding the available systems.
class SchoolSystem {
final String key;
final String displayName;
final String? logoUrl;

/// How private mode authenticates and fetches data for this system:
/// `token` (a headless login mints a bearer token + refreshable session) or
/// `scrape` (credentials replayed per fetch). Lets the app pick a strategy
/// without knowing the provider.
final String? privateAuthStrategy;

/// Base path of this system's stateless plugin endpoints (private mode),
/// e.g. `/api/plugins/<plugin>/stateless`. Served by the catalog.
final String? statelessBasePath;

/// Base path of this system's plugin endpoints (account mode:
/// accounts/sync/status), e.g. `/api/plugins/<plugin>`. Served by the
/// catalog because the system key differs from the plugin name.
final String? pluginBasePath;

/// How the app drives the login: `oauth-webview` or `credentials`.
final String loginMethod;
final bool enabled;
final int sortOrder;
Expand Down Expand Up @@ -59,12 +49,10 @@ class SchoolSystem {
}
}

/// One input the app renders on a system's login form.
class SchoolSystemLoginField {
final String key;
final String label;

/// Input hint: `url`, `text` or `password`.
final String type;
final String? placeholder;
final String? defaultValue;
Expand Down
4 changes: 0 additions & 4 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -36,16 +36,12 @@ class SchulyApp extends StatelessWidget {
theme: FThemes.zinc.light.toApproximateMaterialTheme(),
darkTheme: FThemes.zinc.dark.toApproximateMaterialTheme(),
builder: (ctx, child) {
// Resolve the active Forui theme from the mode, following the OS
// brightness when set to system.
final mode = ThemeService.instance.mode;
final platformDark =
MediaQuery.platformBrightnessOf(ctx) == Brightness.dark;
final isDark = mode == ThemeMode.dark ||
(mode == ThemeMode.system && platformDark);
final theme = isDark ? FThemes.zinc.dark : FThemes.zinc.light;
// App-wide toaster, anchored at the top so all toasts drop down
// from the top edge instead of rising from the bottom.
return FAnimatedTheme(
data: theme,
child: FToaster(
Expand Down
13 changes: 0 additions & 13 deletions lib/services/active_account_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,6 @@ import '../domain/my_school.dart';
import 'api_client.dart';
import 'school_systems_service.dart';

/// App-wide source of truth for "which connected school is the user currently
/// looking at". Backed by `GET /api/schools/my-schools`. Listens-friendly via
/// [ChangeNotifier] so the avatar, the side sheet, and the dashboard rebuild
/// from one place.
class ActiveAccountService extends ChangeNotifier {
ActiveAccountService._();
static final ActiveAccountService instance = ActiveAccountService._();
Expand Down Expand Up @@ -56,7 +52,6 @@ class ActiveAccountService extends ChangeNotifier {
pluginAccountId: info?.accountId);
}).toList(growable: false);

// Keep the persisted active id only if it still resolves to a school.
final prefs = await SharedPreferences.getInstance();
final persisted = prefs.getString(_activeIdKey);
if (persisted != null && _schools.any((s) => s.id == persisted)) {
Expand All @@ -77,11 +72,6 @@ class ActiveAccountService extends ChangeNotifier {
}
}

/// Maps schoolId → (provider, plugin account id, plugin base path) by
/// cross-referencing each catalog system's plugin accounts (which expose
/// `schoolUserId` + `id`) against the user's SchoolUsers. The set of plugins
/// and their routes comes entirely from the backend catalog - no provider is
/// hardcoded. Best-effort: returns an empty map on any failure.
Future<Map<String, ({String provider, String accountId, String? pluginBasePath})>>
_detectPluginAccounts() async {
try {
Expand Down Expand Up @@ -137,14 +127,11 @@ class ActiveAccountService extends ChangeNotifier {
notifyListeners();
}

/// Disconnects a connected school via its plugin's DELETE endpoint (built from
/// the catalog's plugin base path), then reloads the account list.
Future<void> removeSchool(MySchool school) async {
final accountId = school.pluginAccountId;
final base = school.pluginBasePath;
if (accountId == null || base == null || base.isEmpty) return;
await ApiClient.instance.dio.delete<dynamic>('$base/accounts/$accountId');
// If we removed the active school, drop the selection so refresh picks a new one.
if (_activeId == school.id) {
_activeId = null;
final prefs = await SharedPreferences.getInstance();
Expand Down
18 changes: 0 additions & 18 deletions lib/services/api_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,8 @@ import 'auth_service.dart';
import 'backend_dio.dart';
import 'toast_service.dart';

/// Singleton-ish wrapper around the generated [SchulyApi]. Pre-wires the
/// backend base URL and an interceptor that attaches the Pocket ID bearer on
/// every request and transparently refreshes it on a 401.
class ApiClient {
ApiClient._() {
// The unified plugin login runs the initial sync inline, which takes well
// over the generated client's 3s default on cold runs.
_dio = backendDio(
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 120),
Expand All @@ -30,29 +25,22 @@ class ApiClient {
},
onError: (e, handler) async {
final options = e.requestOptions;
// On a 401, try to refresh the access token once and replay the
// request. `_retried` guards against an infinite loop if the
// refreshed token is also rejected.
if (e.response?.statusCode == 401 && options.extra['_retried'] != true) {
final newToken = await _refresh();
if (newToken != null) {
options.extra['_retried'] = true;
options.headers['Authorization'] = 'Bearer $newToken';
try {
// Silent on success - a refreshed-and-retried request is normal.
return handler.resolve(await _dio.fetch(options));
} on DioException catch (retryError) {
_toastHttpError(retryError);
return handler.next(retryError);
}
}
// Refresh failed → the refresh token is dead too. Clear the
// session so the auth gate bounces the user to sign-in.
ToastService.error('Session expired', 'Please sign in again.');
await AuthService.signOut();
return handler.next(e);
}
// Surface every other HTTP / network failure so it isn't silent.
_toastHttpError(e);
handler.next(e);
},
Expand All @@ -65,12 +53,8 @@ class ApiClient {
late final Dio _dio;
late final SchulyApi api;

/// The configured Dio (auth + refresh interceptor, backend base URL) for
/// requests the typed client doesn't cover well - e.g. binary downloads.
Dio get dio => _dio;

/// In-flight refresh, shared so concurrent 401s trigger a single token
/// exchange instead of a stampede.
Future<String?>? _refreshing;

Future<String?> _refresh() {
Expand All @@ -79,8 +63,6 @@ class ApiClient {
}
}

/// Toasts an HTTP / network failure so API errors aren't silent - the status
/// code (or "network") plus the method and path that failed.
void _toastHttpError(DioException e) {
final code = e.response?.statusCode;
final r = e.requestOptions;
Expand Down
5 changes: 0 additions & 5 deletions lib/services/app_mode_service.dart
Original file line number Diff line number Diff line change
@@ -1,13 +1,8 @@
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';

/// How the app runs:
/// - [account] - full mode: Pocket ID sign-in + the Schuly backend account.
/// - [private] - no account; data is proxied statelessly and kept only on-device.
enum AppMode { account, private }

/// Holds the selected [AppMode], persisted locally. Mirrors [ThemeService]:
/// a singleton [ChangeNotifier] loaded once at startup.
class AppModeService extends ChangeNotifier {
AppModeService._();
static final AppModeService instance = AppModeService._();
Expand Down
Loading
Loading