From 03ed12b5ee1268368d66c2b7c4138495a2a9a74b Mon Sep 17 00:00:00 2001 From: PianoNic <79938743+Pianonic@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:16:03 +0200 Subject: [PATCH] Drop comments that restate the code Remove doc and inline comments that repeat the declaration below them. Keep the ones recording a decision: in-memory access tokens, the system-browser login, the http allowance for self-hosted backends, and the PageView carousel. --- lib/config/backend_config.dart | 15 ------------ lib/config/oidc_config.dart | 12 ---------- lib/domain/my_school.dart | 8 ------- lib/domain/private_data.dart | 4 ---- lib/domain/school_system.dart | 12 ---------- lib/main.dart | 4 ---- lib/services/active_account_service.dart | 13 ---------- lib/services/api_client.dart | 18 -------------- lib/services/app_mode_service.dart | 5 ---- lib/services/auth_service.dart | 24 ------------------- lib/services/backend_dio.dart | 3 --- lib/services/onboarding_service.dart | 3 --- lib/services/private_account_store.dart | 14 ----------- lib/services/private_data_adapter.dart | 9 ------- lib/services/school_data_service.dart | 22 ----------------- lib/services/scrape_proxy_client.dart | 1 - lib/services/theme_service.dart | 2 -- lib/services/toast_service.dart | 1 - lib/services/token_proxy_client.dart | 19 --------------- lib/services/totp_service.dart | 18 -------------- lib/services/totp_vault.dart | 14 ----------- lib/ui/absences/absences_page.dart | 6 ----- lib/ui/account/account_page.dart | 13 ---------- lib/ui/account/unified_connect_screen.dart | 6 ----- lib/ui/authenticator/add_totp_screen.dart | 4 ---- .../authenticator_vault_screen.dart | 12 ---------- lib/ui/authenticator/totp_field_picker.dart | 9 ------- lib/ui/authenticator/totp_scan_screen.dart | 6 ----- lib/ui/classes/class_detail_screen.dart | 2 -- lib/ui/core/grade_color.dart | 5 ---- lib/ui/core/ui/root_screen.dart | 9 ------- lib/ui/dashboard/dashboard_screen.dart | 7 ------ .../dashboard/widgets/accounts_sidebar.dart | 21 ---------------- .../dashboard/widgets/add_school_modal.dart | 14 ----------- lib/ui/documents/documents_page.dart | 12 ---------- lib/ui/grades/grades_page.dart | 11 --------- lib/ui/home/home_page.dart | 10 -------- lib/ui/onboarding/onboarding_screen.dart | 14 ----------- lib/ui/private/private_connect_flow.dart | 3 --- lib/ui/private/private_connect_screen.dart | 13 ---------- lib/ui/settings/settings_screen.dart | 19 --------------- lib/ui/timetable/timetable_page.dart | 7 ------ lib/ui/widgets/dynamic_login_form.dart | 12 ---------- test/totp_service_test.dart | 1 - 44 files changed, 437 deletions(-) diff --git a/lib/config/backend_config.dart b/lib/config/backend_config.dart index 83bdf19..2d55441 100644 --- a/lib/config/backend_config.dart +++ b/lib/config/backend_config.dart @@ -15,8 +15,6 @@ 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', @@ -24,10 +22,8 @@ class BackendConfig { 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 load() async { @@ -35,13 +31,9 @@ class BackendConfig { 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; @@ -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 probe(String baseUrl) async { final url = normalise(baseUrl); if (url.isEmpty) return null; @@ -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 setUrl(String? value) async { final prefs = await SharedPreferences.getInstance(); final v = value?.trim().replaceAll(RegExp(r'/+$'), ''); diff --git a/lib/config/oidc_config.dart b/lib/config/oidc_config.dart index 8c21291..ea128d6 100644 --- a/lib/config/oidc_config.dart +++ b/lib/config/oidc_config.dart @@ -28,7 +28,6 @@ class OidcSettings { this.endSessionEndpoint, }); - /// The OIDC scopes as a list, split from the space-delimited [scope] string. List get scopes => scope.split(' ').where((s) => s.isNotEmpty).toList(); /// Deep-link scheme the provider redirects back to (e.g. `schulytest`), @@ -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? _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 settings() { final cached = _settings; if (cached != null) return Future.value(cached); @@ -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; @@ -98,9 +89,6 @@ class OidcConfig { return jsonDecode(r.body) as Map; } - /// 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; diff --git a/lib/domain/my_school.dart b/lib/domain/my_school.dart index c5341a8..bf05788 100644 --- a/lib/domain/my_school.dart +++ b/lib/domain/my_school.dart @@ -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; diff --git a/lib/domain/private_data.dart b/lib/domain/private_data.dart index 86c9bb9..6e86215 100644 --- a/lib/domain/private_data.dart +++ b/lib/domain/private_data.dart @@ -1,6 +1,3 @@ -// Typed responses from the backend's stateless plugin proxies -// (`/api/plugins//stateless/*`), used by private mode. Field names -// mirror the plugins' flat DTOs (camelCase JSON). class PrivateRefreshResult { final bool success; @@ -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({ diff --git a/lib/domain/school_system.dart b/lib/domain/school_system.dart index b9cde3a..4df21f4 100644 --- a/lib/domain/school_system.dart +++ b/lib/domain/school_system.dart @@ -1,19 +1,10 @@ -/// 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//stateless`. Served by the catalog. final String? statelessBasePath; /// Base path of this system's plugin endpoints (account mode: @@ -21,7 +12,6 @@ class SchoolSystem { /// 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; @@ -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; diff --git a/lib/main.dart b/lib/main.dart index f014a1e..3e21ed1 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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( diff --git a/lib/services/active_account_service.dart b/lib/services/active_account_service.dart index 11955b9..9ed0c24 100644 --- a/lib/services/active_account_service.dart +++ b/lib/services/active_account_service.dart @@ -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._(); @@ -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)) { @@ -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> _detectPluginAccounts() async { try { @@ -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 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('$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(); diff --git a/lib/services/api_client.dart b/lib/services/api_client.dart index b2ba01c..3bf9a02 100644 --- a/lib/services/api_client.dart +++ b/lib/services/api_client.dart @@ -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), @@ -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); }, @@ -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? _refreshing; Future _refresh() { @@ -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; diff --git a/lib/services/app_mode_service.dart b/lib/services/app_mode_service.dart index 701746f..112fe5f 100644 --- a/lib/services/app_mode_service.dart +++ b/lib/services/app_mode_service.dart @@ -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._(); diff --git a/lib/services/auth_service.dart b/lib/services/auth_service.dart index 1db9996..b939319 100644 --- a/lib/services/auth_service.dart +++ b/lib/services/auth_service.dart @@ -36,7 +36,6 @@ class AuthService { aOptions: AndroidOptions(encryptedSharedPreferences: true), ); - /// The current access token, held in memory only, with its expiry. static String? _accessToken; static DateTime? _accessTokenExpiry; @@ -46,10 +45,8 @@ class AuthService { static Future _ensureMigrated() => _migration ??= _migrate(); static Future _migrate() async { - // Drop any access token an old build persisted - it's memory-only now. await _storage.delete(key: _kLegacyAccessTokenKey); - // Already in secure storage → nothing to carry over. if (await _storage.containsKey(key: _kRefreshTokenKey)) return; final prefs = await SharedPreferences.getInstance(); @@ -65,8 +62,6 @@ class AuthService { await prefs.remove(_kRefreshTokenKey); } - /// Bumped whenever the session changes (sign-out / expiry). The auth gate - /// listens to re-evaluate whether to show the sign-in screen. static final ValueNotifier sessionEpoch = ValueNotifier(0); static AuthorizationServiceConfiguration _serviceConfig(OidcSettings cfg) => AuthorizationServiceConfiguration(authorizationEndpoint: cfg.authorizationEndpoint, tokenEndpoint: cfg.tokenEndpoint, endSessionEndpoint: cfg.endSessionEndpoint); @@ -96,8 +91,6 @@ class AuthService { final tokens = AuthTokens(accessToken: r.accessToken!, idToken: r.idToken, refreshToken: r.refreshToken, accessTokenExpiry: r.accessTokenExpirationDateTime); _accessToken = tokens.accessToken; _accessTokenExpiry = tokens.accessTokenExpiry; - // Keycloak rotates refresh tokens by default; persist the new one every time - // or the next refresh fails. Fall back to the existing one if omitted. if (tokens.refreshToken != null) { await _storage.write(key: _kRefreshTokenKey, value: tokens.refreshToken!); } @@ -113,7 +106,6 @@ class AuthService { static Future getAccessToken() async { final token = _accessToken; final expiry = _accessTokenExpiry; - // Treat tokens within 30s of expiry as stale to avoid using one mid-flight. if (token != null && expiry != null && expiry.isAfter(DateTime.now().add(const Duration(seconds: 30)))) { return token; } @@ -125,14 +117,8 @@ class AuthService { return _storage.read(key: _kRefreshTokenKey); } - /// In-flight refresh, shared so concurrent callers trigger a single token - /// exchange instead of a stampede. static Future? _refreshing; - /// Exchanges the stored refresh token for a fresh access token and persists - /// the rotated result. Returns the new access token, or null if there's no - /// refresh token or the exchange failed - in which case the caller should - /// treat the session as expired. static Future refreshAccessToken() => _refreshing ??= _refresh().whenComplete(() => _refreshing = null); static Future _refresh() async { @@ -157,10 +143,6 @@ class AuthService { } } - /// Decodes the persisted OIDC ID token's payload. Returns its claims - /// (`name`, `email`, `picture`, …) or null if there's no token / it's - /// malformed. Pure local decode - no signature verification, which is fine - /// since the token was already validated at exchange time. static Future?> getIdTokenClaims() async { await _ensureMigrated(); final idToken = await _storage.read(key: _kIdTokenKey); @@ -175,10 +157,6 @@ class AuthService { } } - /// Full logout: end the Keycloak SSO session at the `end_session_endpoint` - /// (deleting local tokens alone leaves the browser session alive, so the next - /// login would silently succeed), then wipe local state. The end-session call - /// is best-effort - local state is cleared regardless. static Future signOut() async { final idToken = await _storage.read(key: _kIdTokenKey); try { @@ -194,14 +172,12 @@ class AuthService { ); } } catch (_) { - // Ignore - the user still gets signed out locally below. } _accessToken = null; _accessTokenExpiry = null; await _storage.delete(key: _kRefreshTokenKey); await _storage.delete(key: _kIdTokenKey); await _storage.delete(key: _kLegacyAccessTokenKey); - // Clear any tokens an older build may have left in SharedPreferences too. final prefs = await SharedPreferences.getInstance(); await prefs.remove(_kLegacyAccessTokenKey); await prefs.remove(_kIdTokenKey); diff --git a/lib/services/backend_dio.dart b/lib/services/backend_dio.dart index adea594..d07baf8 100644 --- a/lib/services/backend_dio.dart +++ b/lib/services/backend_dio.dart @@ -2,9 +2,6 @@ import 'package:dio/dio.dart'; import '../config/backend_config.dart'; -/// Builds a [Dio] whose base URL always tracks [BackendConfig.url]: an interceptor -/// re-points it on every request, so a runtime backend change (Settings -> Server) -/// takes effect immediately - no per-client `applyBaseUrl()` bookkeeping. Dio backendDio({Duration? connectTimeout, Duration? receiveTimeout, Duration? sendTimeout}) { final dio = Dio(BaseOptions( baseUrl: BackendConfig.url, diff --git a/lib/services/onboarding_service.dart b/lib/services/onboarding_service.dart index 2edfa0e..be290fb 100644 --- a/lib/services/onboarding_service.dart +++ b/lib/services/onboarding_service.dart @@ -1,8 +1,5 @@ import 'package:shared_preferences/shared_preferences.dart'; -/// Tracks whether the first-run onboarding has been completed, persisted -/// locally. The flag is versioned so a future revamp can re-show it by bumping -/// the key. class OnboardingService { OnboardingService._(); diff --git a/lib/services/private_account_store.dart b/lib/services/private_account_store.dart index 24bc8f4..7f548e4 100644 --- a/lib/services/private_account_store.dart +++ b/lib/services/private_account_store.dart @@ -2,39 +2,25 @@ import 'dart:convert'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -/// On-device credentials for a private-mode (account-free) connection. -/// Held only in the device keystore - never sent to or stored on a Schuly -/// account. Provider-agnostic: [loginMethod] (from the backend catalog) drives -/// how the connection authenticates and where its data is fetched. class PrivateAccount { - /// The catalog system key this connection belongs to. final String systemKey; - /// Backend-provided discriminator: `oauth-webview` or `credentials`. final String loginMethod; final String baseUrl; final String displayName; - /// Base path of the system's stateless plugin endpoints (from the catalog). final String statelessBasePath; - // oauth-webview: final String? accessToken; final String? refreshToken; - /// Opaque Playwright storage_state blob (JSON string) for passwordless refresh. final String? contextState; - /// Exact WebView user-agent captured at login (Microsoft pins cookies to UA). final String? userAgent; - // credentials: final String? username; final String? password; - /// TOTP seed (normalized base32). Lets Schuly regenerate the second factor - /// on-device - for silent re-login and the in-app authenticator. Stored only - /// in the device keystore, like every other field here. final String? totpSecret; const PrivateAccount({ diff --git a/lib/services/private_data_adapter.dart b/lib/services/private_data_adapter.dart index 63a5791..c50014a 100644 --- a/lib/services/private_data_adapter.dart +++ b/lib/services/private_data_adapter.dart @@ -3,12 +3,7 @@ import 'package:schuly_api/schuly_api.dart'; import '../domain/private_data.dart'; -/// Adapts the stateless proxy's flat private-mode DTOs into the generated -/// `SchulyApi` DTOs the account-mode UI already renders, so private mode reuses -/// the same screens. Date formats and a few field choices are best-guess until -/// validated against a real account. class PrivateDataAdapter { - /// Synthetic SchoolUser id linking the private user's grades/absences. static const privateSchoolUserId = 'private-self'; static DateTime? _date(String? s) => @@ -74,10 +69,6 @@ class PrivateDataAdapter { static List agenda(List e) => e.map(agendaEntry).toList(growable: false); - /// Derives the student's classes from the distinct subjects across their - /// grades and exams, attaching each subject's exams - mirroring how the - /// account-mode backend sync creates a Class per course. (The token-strategy - /// endpoints have no standalone classes source, so this is the private-mode analogue.) static List classes( List grades, List exams, diff --git a/lib/services/school_data_service.dart b/lib/services/school_data_service.dart index f7ea65a..bd31fc1 100644 --- a/lib/services/school_data_service.dart +++ b/lib/services/school_data_service.dart @@ -10,12 +10,6 @@ import 'private_data_adapter.dart'; import 'scrape_proxy_client.dart'; import 'token_proxy_client.dart'; -/// Loads and caches the per-school data the UI renders: the signed-in user's -/// SchoolUser record (with nested grades/absences/classes), plus the school's -/// exams and agenda. Everything is filtered to [ActiveAccountService.active]. -/// -/// The backend scopes responses to the authenticated user but doesn't filter -/// by school, so we filter by `schoolId` client-side. class SchoolDataService extends ChangeNotifier { SchoolDataService._(); static final SchoolDataService instance = SchoolDataService._(); @@ -42,7 +36,6 @@ class SchoolDataService extends ChangeNotifier { bool get loading => _loading; Object? get error => _error; - /// Friendly class name by class id, from the full ClassDto. Map get classNameById { final out = {}; for (final c in _classes) { @@ -53,7 +46,6 @@ class SchoolDataService extends ChangeNotifier { SchulyApi get _api => ApiClient.instance.api; - /// My grades for the active school, keyed by examId. Map get myGradesByExam { final out = {}; final grades = _me?.grades; @@ -85,7 +77,6 @@ class SchoolDataService extends ChangeNotifier { _error = null; notifyListeners(); try { - // Who am I → my SchoolUser for this school (carries nested grades etc.). final me = await _api.getAuthApi().apiAuthMeGet(); final appUserId = me.data?.id; if (appUserId != null) { @@ -103,16 +94,12 @@ class SchoolDataService extends ChangeNotifier { .where((e) => e.schoolId == schoolId) .toList(growable: false); - // Agenda entries carry a classId but no schoolId, so scope them by the - // user's classes (their classes all belong to the active school). final myClassIds = { for (final c in (_me?.classes ?? const [])) c.classId, }; final meId = _me?.id; final agenda = await _api.getAgendasApi().apiAgendasGet(); _agenda = (agenda.data ?? BuiltList()) - // Scraped lessons + holidays are scoped to the SchoolUser (no class); - // class-scoped entries are matched by the user's class membership. .where((a) => (meId != null && a.schoolUserId == meId) || a.entryType == AgendaEntryType.holiday || @@ -152,9 +139,6 @@ class SchoolDataService extends ChangeNotifier { } } - /// Private mode: pull data from the stateless proxy with the on-device - /// credentials and adapt it into the same DTOs the UI renders. Nothing here - /// touches a Schuly account; features without a proxy source stay empty. Future _refreshPrivate() async { final account = await PrivateAccountStore.instance.load(); if (account == null) { @@ -167,9 +151,6 @@ class SchoolDataService extends ChangeNotifier { notifyListeners(); try { if (account.accessToken != null) { - // Token-strategy systems: batched endpoints with a passwordless token - // refresh on expiry. A stored access token is the marker - token logins - // mint one; scrape (credential-replay) systems don't. final d = await TokenProxyClient.instance.fetchAll(account); if (d.refreshedAccount != null) { await PrivateAccountStore.instance.save(d.refreshedAccount!); @@ -180,7 +161,6 @@ class SchoolDataService extends ChangeNotifier { _agenda = PrivateDataAdapter.agenda(d.agenda); _classes = PrivateDataAdapter.classes(d.grades, d.exams); } else { - // Scrape-strategy systems: one scrape pass returns everything. final d = await ScrapeProxyClient.instance.data(account); _me = PrivateDataAdapter.schoolUser(d.userInfo, d.grades, const []); _exams = PrivateDataAdapter.exams(d.exams); @@ -188,8 +168,6 @@ class SchoolDataService extends ChangeNotifier { _agenda = PrivateDataAdapter.agenda(d.agenda); _classes = PrivateDataAdapter.classes(d.grades, d.exams); } - // Reports, teachers and documents are scraper-only / not exposed by the - // token-strategy endpoints, so they have no stateless source in private mode. _reports = const []; _teachers = const []; _documents = const []; diff --git a/lib/services/scrape_proxy_client.dart b/lib/services/scrape_proxy_client.dart index 69e36c3..5db2da4 100644 --- a/lib/services/scrape_proxy_client.dart +++ b/lib/services/scrape_proxy_client.dart @@ -4,7 +4,6 @@ import '../domain/private_data.dart'; import 'backend_dio.dart'; import 'private_account_store.dart'; -/// Bundle returned by the stateless scrape proxy in one pass. class ScrapeData { final PrivateUserInfo? userInfo; final List grades; diff --git a/lib/services/theme_service.dart b/lib/services/theme_service.dart index 53b2c4a..ac0ac9a 100644 --- a/lib/services/theme_service.dart +++ b/lib/services/theme_service.dart @@ -1,8 +1,6 @@ import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; -/// App-wide theme-mode preference (system / light / dark), persisted across -/// launches. The root [MaterialApp] + Forui `FAnimatedTheme` rebuild from this. class ThemeService extends ChangeNotifier { ThemeService._(); static final ThemeService instance = ThemeService._(); diff --git a/lib/services/toast_service.dart b/lib/services/toast_service.dart index e05c826..7fa54d2 100644 --- a/lib/services/toast_service.dart +++ b/lib/services/toast_service.dart @@ -33,7 +33,6 @@ class ToastService { ); } - /// Turn an exception into a short, single-line, user-facing detail. static String? _clean(Object? detail) { if (detail == null) return null; var s = detail.toString().replaceAll(RegExp(r'\s+'), ' ').trim(); diff --git a/lib/services/token_proxy_client.dart b/lib/services/token_proxy_client.dart index 6f04622..4e6844c 100644 --- a/lib/services/token_proxy_client.dart +++ b/lib/services/token_proxy_client.dart @@ -21,8 +21,6 @@ class TokenProxyClient { receiveTimeout: const Duration(seconds: 60), ); - // --- Auth --- - /// Headless credential login (private mode): POST email + password (+ TOTP) to /// the stateless `/login` and get back tokens + the rotated context_state. Future login({ @@ -44,7 +42,6 @@ class TokenProxyClient { return _parse(res.data ?? const {}); } - /// Passwordless refresh from a stored context_state (JSON string). Future refresh({ required String basePath, required String baseUrl, @@ -79,8 +76,6 @@ class TokenProxyClient { ); } - // --- Data --- - Future> grades(PrivateAccount a) => _list('/grades', a, PrivateGrade.fromJson); @@ -101,10 +96,6 @@ class TokenProxyClient { return res.data == null ? null : PrivateUserInfo.fromJson(res.data!); } - /// Fetches everything the private dashboard needs. If the access token has - /// expired (a 401 from any call), does one passwordless refresh from the - /// stored `context_state`, retries, and reports the rotated account back via - /// [TokenPrivateData.refreshedAccount] so the caller can persist it. Future fetchAll(PrivateAccount account) async { try { return await _fetchAll(account, null); @@ -139,8 +130,6 @@ class TokenProxyClient { /// back to a full credential re-login from the vaulted email/password/seed - /// Schuly regenerates the OTP itself, so the user is never prompted. Future _refreshAccount(PrivateAccount a) async { - // Credential logins (ms-entrance) have no captured user-agent - it manages - // its own - so only context_state is required to replay. if (a.contextState != null) { final r = await refresh( basePath: a.statelessBasePath, @@ -153,9 +142,6 @@ class TokenProxyClient { return _credentialRelogin(a); } - /// Silent re-login from the stored credentials + TOTP seed. Returns null when - /// the seed/credentials weren't stored (e.g. an older connection) or login - /// failed, leaving the caller to surface a reconnect prompt. Future _credentialRelogin(PrivateAccount a) async { final email = a.username; final password = a.password; @@ -167,15 +153,12 @@ class TokenProxyClient { baseUrl: a.baseUrl, email: email, password: password, - // The backend computes the code from the seed; send only the base32. totpSecret: TotpService.secretOf(a.totpSecret), ); if (!r.success || r.accessToken == null) return null; return _applied(a, r); } - /// Builds the rotated account from a refresh/login result, carrying the - /// vaulted credentials + seed forward so the next refresh can fall back too. PrivateAccount _applied(PrivateAccount a, PrivateRefreshResult r) => PrivateAccount( systemKey: a.systemKey, @@ -210,8 +193,6 @@ class TokenProxyClient { }; } -/// Everything the private dashboard pulls in one pass. [refreshedAccount] is -/// non-null when the token was refreshed mid-fetch and should be persisted. class TokenPrivateData { final PrivateUserInfo? userInfo; final List grades; diff --git a/lib/services/totp_service.dart b/lib/services/totp_service.dart index 5895a11..ec0f2cc 100644 --- a/lib/services/totp_service.dart +++ b/lib/services/totp_service.dart @@ -1,9 +1,6 @@ import 'package:otp/otp.dart'; -/// A parsed TOTP descriptor. Built from either a bare base32 secret or a full -/// `otpauth://totp/...` URI (as encoded in an authenticator QR code). class TotpConfig { - /// Normalized base32 secret - no spaces/dashes, upper-case. final String secret; final int digits; final int period; // seconds @@ -20,8 +17,6 @@ class TotpConfig { this.account, }); - /// Parses [raw], which may be a bare base32 secret or an `otpauth://` URI. - /// Returns null when no usable secret can be extracted. static TotpConfig? tryParse(String? raw) { final input = raw?.trim() ?? ''; if (input.isEmpty) return null; @@ -32,7 +27,6 @@ class TotpConfig { final secret = normalizeSecret(uri.queryParameters['secret'] ?? ''); if (secret.isEmpty) return null; - // Label is `Issuer:Account` (issuer optional); `issuer` query param wins. String? issuer = uri.queryParameters['issuer']; String? account = uri.pathSegments.isNotEmpty ? uri.pathSegments.last : null; @@ -56,8 +50,6 @@ class TotpConfig { return secret.isEmpty ? null : TotpConfig(secret: secret); } - /// Strips spaces/dashes and upper-cases - accepts the way authenticators - /// display seeds (grouped, lower-case) as well as the raw form. static String normalizeSecret(String s) => s.replaceAll(RegExp(r'[\s-]'), '').toUpperCase(); @@ -73,7 +65,6 @@ class TotpConfig { } } -/// A generated code together with how long it stays valid. class TotpCode { final String code; final int secondsRemaining; @@ -84,18 +75,12 @@ class TotpCode { required this.period, }); - /// 1.0 right after a rollover → 0.0 just before the next one. double get fraction => period <= 0 ? 0 : secondsRemaining / period; } -/// On-device TOTP (RFC 6238). Lets Schuly act as the authenticator: it both -/// powers the in-app code display and lets private-mode re-authenticate from a -/// vaulted seed without the user re-typing a 6-digit code. class TotpService { TotpService._(); - /// Current code for [config] at [at] (defaults to now), or null if the secret - /// can't be used (e.g. invalid base32). static TotpCode? generate(TotpConfig config, {DateTime? at}) { final now = at ?? DateTime.now(); final period = config.period <= 0 ? 30 : config.period; @@ -116,12 +101,9 @@ class TotpService { } } - /// The base32 secret extracted from a stored seed/URI (what the backend - /// `/login` expects), or null when none can be parsed. static String? secretOf(String? secretOrUri) => TotpConfig.tryParse(secretOrUri)?.secret; - /// Convenience: current code string for a stored seed/URI, or null. static String? codeFor(String? secretOrUri, {DateTime? at}) { final config = TotpConfig.tryParse(secretOrUri); return config == null ? null : generate(config, at: at)?.code; diff --git a/lib/services/totp_vault.dart b/lib/services/totp_vault.dart index 4f8ca51..513dec1 100644 --- a/lib/services/totp_vault.dart +++ b/lib/services/totp_vault.dart @@ -4,11 +4,6 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'totp_service.dart'; -/// One saved authenticator entry: a TOTP secret plus display metadata. The -/// [secretOrUri] is what [TotpService] consumes - a bare base32 secret or a full -/// `otpauth://` URI (as encoded in an authenticator QR code). Provider-agnostic: -/// any service's 2FA can live here. Held only in the device keystore, never -/// synced to a Schuly account. class TotpEntry { final String id; final String secretOrUri; @@ -17,18 +12,14 @@ class TotpEntry { const TotpEntry({required this.id, required this.secretOrUri, this.issuer, this.account}); - /// The normalized base32 secret (what a backend `/login` expects), or null if - /// [secretOrUri] can't be parsed. String? get secret => TotpService.secretOf(secretOrUri); - /// Primary display line - issuer if known, else the account, else a fallback. String get title { if (issuer != null && issuer!.isNotEmpty) return issuer!; if (account != null && account!.isNotEmpty) return account!; return 'Account'; } - /// Secondary display line - the account when a distinct issuer is shown. String? get subtitle => (issuer != null && issuer!.isNotEmpty && account != null && account!.isNotEmpty) ? account : null; Map toJson() => {'id': id, 'secretOrUri': secretOrUri, 'issuer': issuer, 'account': account}; @@ -40,10 +31,6 @@ class TotpEntry { account: json['account'] as String?, ); - /// Builds an entry from a raw scanned/typed [payload] (an `otpauth://` URI or - /// bare secret), pulling issuer/account from the URI when present. Returns null - /// when no usable TOTP secret can be extracted. [id] is caller-supplied so the - /// factory stays deterministic; pass a fresh unique value. static TotpEntry? fromPayload(String id, String payload, {String? issuer, String? account}) { final config = TotpConfig.tryParse(payload); if (config == null) return null; @@ -82,7 +69,6 @@ class TotpVault { Future _saveAll(List entries) => _storage.write(key: _key, value: jsonEncode(entries.map((e) => e.toJson()).toList())); - /// Adds [entry], replacing any existing one with the same id, and returns it. Future add(TotpEntry entry) async { final entries = await load(); entries.removeWhere((e) => e.id == entry.id); diff --git a/lib/ui/absences/absences_page.dart b/lib/ui/absences/absences_page.dart index c84ef13..04a5aec 100644 --- a/lib/ui/absences/absences_page.dart +++ b/lib/ui/absences/absences_page.dart @@ -6,8 +6,6 @@ import 'package:schuly_api/schuly_api.dart'; import '../../services/api_client.dart'; import '../../services/school_data_service.dart'; -/// Absences tab - lists the user's absences/delays and lets them report, -/// edit, and delete (the one writable area). class AbsencesPage extends StatelessWidget { const AbsencesPage({super.key}); @@ -93,7 +91,6 @@ class _TypeBadge extends StatelessWidget { } } -/// Bottom-sheet form to create or edit an absence. class _AbsenceForm extends StatefulWidget { final AbsenceDto? existing; const _AbsenceForm({this.existing}); @@ -203,9 +200,6 @@ class _AbsenceFormState extends State<_AbsenceForm> { Widget build(BuildContext context) { final colors = context.theme.colors; String d(DateTime x) => '${x.day}.${x.month}.${x.year}'; - // showFSheet strips MediaQuery.padding (so SafeArea is a no-op); viewPadding - // survives. Clear whichever is taller - the keyboard (viewInsets) or the - // Android gesture/nav bar (viewPadding) - so the Save button is never hidden. final keyboard = MediaQuery.viewInsetsOf(context).bottom; final navBar = MediaQuery.viewPaddingOf(context).bottom; return Container( diff --git a/lib/ui/account/account_page.dart b/lib/ui/account/account_page.dart index ec7b94a..a713e39 100644 --- a/lib/ui/account/account_page.dart +++ b/lib/ui/account/account_page.dart @@ -12,8 +12,6 @@ import '../../services/toast_service.dart'; import '../classes/class_detail_screen.dart'; import '../documents/documents_page.dart'; -/// Account tab - profile details, enrolled classes, app info, and the account -/// switcher + sign out. class AccountPage extends StatefulWidget { final String? pictureUrl; final String? userName; @@ -44,9 +42,6 @@ class _AccountPageState extends State { _loadSyncStatus(); } - /// Reads the active account's last-sync time / status / error off the plugin's - /// `…/sync` endpoint so the user can see when data was last refreshed and why - /// a sync failed. Future _loadSyncStatus() async { try { final active = ActiveAccountService.instance.active; @@ -66,8 +61,6 @@ class _AccountPageState extends State { } catch (_) {/* non-critical */} } - /// The version shown is the active provider's plugin version, read off its - /// `…/status` endpoint - not the backend app version. Future _loadVersion() async { try { final active = ActiveAccountService.instance.active; @@ -80,9 +73,6 @@ class _AccountPageState extends State { } catch (_) {/* non-critical */} } - /// Triggers an actual provider re-fetch for the active account, then reloads - /// the local data. Unlike pull-to-refresh (which only re-reads the backend), - /// this pulls fresh data from the upstream provider. Future _syncNow() async { final active = ActiveAccountService.instance.active; final accountId = active?.pluginAccountId; @@ -102,7 +92,6 @@ class _AccountPageState extends State { if (mounted) setState(() => _syncMsg = 'Synced just now'); ToastService.success('Synced', 'Fetched fresh data from the provider.'); } on DioException catch (e) { - // The HTTP error itself is toasted centrally by the Dio interceptor. if (mounted) setState(() => _syncMsg = 'Sync failed (${e.response?.statusCode ?? 'network'})'); } catch (e) { if (mounted) setState(() => _syncMsg = 'Sync failed'); @@ -126,7 +115,6 @@ class _AccountPageState extends State { final initial = fullName.isNotEmpty ? fullName.characters.first.toUpperCase() : '?'; final fallback = Text(initial, style: TextStyle(color: colors.mutedForeground, fontWeight: FontWeight.w600)); - // Prefer the school provider's photo (may be relative), fall back to OIDC. final providerPfp = OidcConfig.resolveUrl(me?.profilePictureUrl); final avatarUrl = providerPfp ?? widget.pictureUrl; @@ -140,7 +128,6 @@ class _AccountPageState extends State { physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.fromLTRB(16, 8, 16, 32), children: [ - // Identity header Row( children: [ (avatarUrl == null || avatarUrl.isEmpty) diff --git a/lib/ui/account/unified_connect_screen.dart b/lib/ui/account/unified_connect_screen.dart index 3f5e599..2fd5052 100644 --- a/lib/ui/account/unified_connect_screen.dart +++ b/lib/ui/account/unified_connect_screen.dart @@ -6,12 +6,6 @@ import '../../domain/school_system.dart'; import '../../services/api_client.dart'; import '../widgets/dynamic_login_form.dart'; -/// Generic account-mode connect via the CRM's **unified** login endpoint -/// (`POST /api/auth/login`). Renders the chosen system's catalog `loginFields` -/// and forwards them as `{ systemKey, fields, displayName }`; the dumb CRM routes -/// to the owning plugin (`IPluginLogin`) which authenticates the provider. No -/// provider logic and no WebView here - works for any `credentials` system. -/// Pops the new account id (`String`) on success. class UnifiedConnectScreen extends StatefulWidget { final SchoolSystem system; const UnifiedConnectScreen({required this.system, super.key}); diff --git a/lib/ui/authenticator/add_totp_screen.dart b/lib/ui/authenticator/add_totp_screen.dart index 7cf1afd..515215c 100644 --- a/lib/ui/authenticator/add_totp_screen.dart +++ b/lib/ui/authenticator/add_totp_screen.dart @@ -5,9 +5,6 @@ import '../../services/totp_service.dart'; import '../../services/totp_vault.dart'; import 'totp_scan_screen.dart'; -/// Adds a TOTP authenticator entry - either by scanning an `otpauth://` QR code -/// with the camera or by typing the setup key manually. Saves the entry to the -/// [TotpVault] and pops the created [TotpEntry] (or null if cancelled). class AddTotpScreen extends StatefulWidget { const AddTotpScreen({super.key}); @@ -30,7 +27,6 @@ class _AddTotpScreenState extends State { super.dispose(); } - /// Unique-enough id for a new entry (keystore is single-writer per device). String _newId() => DateTime.now().microsecondsSinceEpoch.toString(); Future _scan() async { diff --git a/lib/ui/authenticator/authenticator_vault_screen.dart b/lib/ui/authenticator/authenticator_vault_screen.dart index 8e0e2ae..a838c2e 100644 --- a/lib/ui/authenticator/authenticator_vault_screen.dart +++ b/lib/ui/authenticator/authenticator_vault_screen.dart @@ -9,9 +9,6 @@ import '../../services/totp_service.dart'; import '../../services/totp_vault.dart'; import 'add_totp_screen.dart'; -/// One row's source: a parsed TOTP plus its display metadata. [id] is the vault -/// entry id, or null for a pinned, non-deletable row (e.g. a linked school's -/// seed surfaced from the private-mode store). class _Row { final String? id; final String title; @@ -20,10 +17,6 @@ class _Row { const _Row({required this.id, required this.title, required this.config, this.subtitle}); } -/// In-app authenticator vault. Schuly acts as the TOTP client: it lists every -/// saved secret, generates the current code on-device and refreshes each second, -/// with a per-entry countdown and tap-to-copy. Add entries by scanning a QR code -/// or typing the setup key. Provider-agnostic - any service's 2FA can live here. class AuthenticatorVaultScreen extends StatefulWidget { const AuthenticatorVaultScreen({super.key}); @@ -56,8 +49,6 @@ class _AuthenticatorVaultScreenState extends State { final private = await PrivateAccountStore.instance.load(); final rows = <_Row>[]; - // Surface a linked private-mode school's seed as a pinned, non-deletable row - // so the old single-account authenticator keeps working through this screen. final privateConfig = TotpConfig.tryParse(private?.totpSecret); if (privateConfig != null) { rows.add(_Row(id: null, title: private!.displayName, subtitle: 'Linked school', config: privateConfig)); @@ -165,8 +156,6 @@ class _AuthenticatorVaultScreenState extends State { } } -/// A single live-updating code card: title/subtitle, the current code (tap to -/// copy), and a countdown bar. Long-press to delete (deletable rows only). class _CodeCard extends StatelessWidget { final _Row row; final Future Function(String? code) onCopy; @@ -174,7 +163,6 @@ class _CodeCard extends StatelessWidget { const _CodeCard({required this.row, required this.onCopy, required this.onDelete}); - /// `123456` → `123 456` for readability; leaves other lengths untouched. String _format(String code) { if (code.length != 6) return code; return '${code.substring(0, 3)} ${code.substring(3)}'; diff --git a/lib/ui/authenticator/totp_field_picker.dart b/lib/ui/authenticator/totp_field_picker.dart index 46fc4bc..b9f58d6 100644 --- a/lib/ui/authenticator/totp_field_picker.dart +++ b/lib/ui/authenticator/totp_field_picker.dart @@ -5,12 +5,6 @@ import '../../domain/school_system.dart'; import '../../services/totp_vault.dart'; import 'add_totp_screen.dart'; -/// Login-form control for a `totp` field. Instead of typing a raw secret, the -/// user picks a saved authenticator from the [TotpVault] or adds a new one -/// (scanning a QR or entering a key) - the same vault the in-app authenticator -/// uses. The chosen entry's normalized base32 secret is written into -/// [controller] so the connect flow submits it unchanged. Provider-agnostic: -/// rendered for any system that advertises a `totp` field. class TotpFieldPicker extends StatefulWidget { final TextEditingController controller; final SchoolSystemLoginField field; @@ -36,8 +30,6 @@ class _TotpFieldPickerState extends State { if (!mounted) return; setState(() { _entries = entries; - // Re-resolve the current selection against the (possibly changed) vault by - // matching the secret already in the controller. final current = widget.controller.text.trim(); _selected = current.isEmpty ? null : entries.where((e) => e.secret == current).firstOrNull; }); @@ -122,7 +114,6 @@ class _TotpFieldPickerState extends State { enum _PickerKind { none, select, add } -/// Result of the picker menu - which action the user chose. class _PickerAction { final _PickerKind kind; final TotpEntry? entry; diff --git a/lib/ui/authenticator/totp_scan_screen.dart b/lib/ui/authenticator/totp_scan_screen.dart index ffd1122..2b3b683 100644 --- a/lib/ui/authenticator/totp_scan_screen.dart +++ b/lib/ui/authenticator/totp_scan_screen.dart @@ -4,12 +4,6 @@ import 'package:mobile_scanner/mobile_scanner.dart'; import '../../services/totp_service.dart'; -/// Scans an authenticator QR code and returns the raw payload (an -/// `otpauth://` URI, or a bare secret) to the caller. Only codes that parse as -/// a usable TOTP are accepted - other QR codes are ignored so the camera keeps -/// scanning. Pops with the scanned string, or null if cancelled. -/// -/// The [MobileScanner] manages its own camera controller lifecycle. class TotpScanScreen extends StatefulWidget { const TotpScanScreen({super.key}); diff --git a/lib/ui/classes/class_detail_screen.dart b/lib/ui/classes/class_detail_screen.dart index 979c967..c495210 100644 --- a/lib/ui/classes/class_detail_screen.dart +++ b/lib/ui/classes/class_detail_screen.dart @@ -6,8 +6,6 @@ import '../../config/oidc_config.dart'; import '../../services/api_client.dart'; import '../core/grade_color.dart'; -/// Detail for a single class: students, exams, and agenda - fetched on demand -/// via GET /Class/search. class ClassDetailScreen extends StatefulWidget { final String classId; final String title; diff --git a/lib/ui/core/grade_color.dart b/lib/ui/core/grade_color.dart index a6f789d..9773764 100644 --- a/lib/ui/core/grade_color.dart +++ b/lib/ui/core/grade_color.dart @@ -1,8 +1,6 @@ import 'package:flutter/widgets.dart'; import 'package:forui/forui.dart'; -/// Swiss grade scale colouring: 6 best, 1 worst, 4 is the pass mark. -/// ≥5 green, 4–<5 amber, <4 red. Color gradeColor(BuildContext context, num grade) { final colors = context.theme.colors; if (grade >= 5) return const Color(0xFF22C55E); // green @@ -10,8 +8,6 @@ Color gradeColor(BuildContext context, num grade) { return colors.destructive; } -/// A score is only a real grade when it's on the 1–6 scale. 0/null means the -/// exam exists but isn't graded yet - excluded from averages and shown as "-". bool isGraded(num? score) => score != null && score > 0; String formatGrade(num grade) { @@ -21,7 +17,6 @@ String formatGrade(num grade) { : (s.endsWith('0') ? grade.toStringAsFixed(1) : s); } -/// Coloured grade chip. Ungraded (≤0) scores render as a muted "-". class GradePill extends StatelessWidget { final num? score; const GradePill(this.score, {super.key}); diff --git a/lib/ui/core/ui/root_screen.dart b/lib/ui/core/ui/root_screen.dart index 6f01481..30a2f06 100644 --- a/lib/ui/core/ui/root_screen.dart +++ b/lib/ui/core/ui/root_screen.dart @@ -11,9 +11,6 @@ import '../../dashboard/dashboard_screen.dart'; import '../../onboarding/onboarding_screen.dart'; import '../../private/private_connect_flow.dart'; -/// Tier-1 gate. In **account** mode the user signs in with Pocket ID; in -/// **private** mode they connect a school directly (no account) and the creds -/// live only on-device. Once past the gate, [DashboardScreen] owns the rest. class RootScreen extends StatefulWidget { const RootScreen({super.key}); @@ -30,7 +27,6 @@ class _RootScreenState extends State { @override void initState() { super.initState(); - // React to session changes (incl. a failed silent refresh) and mode switches. AuthService.sessionEpoch.addListener(_refresh); AppModeService.instance.addListener(_refresh); OnboardingService.seen().then((seen) { @@ -79,14 +75,10 @@ class _RootScreenState extends State { if (ok) await _refresh(); } - // Onboarding mode choice: mark it seen, select the mode, then run the - // matching gate flow. If the user backs out of sign-in/connect they land on - // the normal gate (onboarding won't show again). Future _onboardWithAccount() async { await OnboardingService.markSeen(); if (mounted) setState(() => _onboarded = true); await AppModeService.instance.setMode(AppMode.account); - // New users coming through onboarding start on the registration screen. await _signIn(register: true); } @@ -119,7 +111,6 @@ class _RootScreenState extends State { if (ready) { return DashboardScreen(onSignOut: _signOut); } - // First run: explain the app and let the user pick a mode. if (!_onboarded) { return OnboardingScreen( onChooseAccount: _onboardWithAccount, diff --git a/lib/ui/dashboard/dashboard_screen.dart b/lib/ui/dashboard/dashboard_screen.dart index de480cb..6fd6369 100644 --- a/lib/ui/dashboard/dashboard_screen.dart +++ b/lib/ui/dashboard/dashboard_screen.dart @@ -15,8 +15,6 @@ import '../timetable/timetable_page.dart'; import 'widgets/accounts_sidebar.dart'; import 'widgets/add_school_modal.dart'; -/// Post-sign-in shell: a 5-tab bottom-navigation app. The top bar carries the -/// profile avatar (opens the account switcher) and the active school name. class DashboardScreen extends StatefulWidget { final VoidCallback onSignOut; const DashboardScreen({super.key, required this.onSignOut}); @@ -50,15 +48,12 @@ class _DashboardScreenState extends State { final id = ActiveAccountService.instance.active?.id; if (id != _lastSchoolId) { _lastSchoolId = id; - // Drop the previous school's cached data so we don't show stale content - // during the switch, then load the new school. SchoolDataService.instance.clear(); SchoolDataService.instance.refresh(); } } Future _bootstrap() async { - // Private mode: no Pocket ID / school switcher - just load proxied data. if (AppModeService.instance.isPrivate) { final account = await PrivateAccountStore.instance.load(); if (mounted) { @@ -165,7 +160,6 @@ class _DashboardScreenState extends State { ), footer: SafeArea( top: false, - // Keep the nav labels clear of the Android gesture/nav bar. child: FBottomNavigationBar( index: _index, onChange: (i) { @@ -244,7 +238,6 @@ class _TopBar extends StatelessWidget { ], ), ), - // Default divider padding is vertical: 20 (huge gap); tighten it. FDivider(style: (s) => s.copyWith(padding: EdgeInsets.zero)), ], ), diff --git a/lib/ui/dashboard/widgets/accounts_sidebar.dart b/lib/ui/dashboard/widgets/accounts_sidebar.dart index 2aa393e..d90a882 100644 --- a/lib/ui/dashboard/widgets/accounts_sidebar.dart +++ b/lib/ui/dashboard/widgets/accounts_sidebar.dart @@ -8,17 +8,7 @@ import '../../authenticator/authenticator_vault_screen.dart'; import '../../settings/settings_screen.dart'; import 'add_school_modal.dart'; -/// Teams-style left-edge account switcher. Top shows the signed-in identity -/// (profile picture + name + email), followed by the list of connected school -/// accounts with a checkmark on the active one, then "Add school account" and -/// "Sign out". -/// -/// The sheet owns the add-account flow: it shows the system picker, pushes the -/// connect screen on the *parent* navigator (so it survives sheet dismissal), -/// and on success refreshes the accounts list and selects the new account. class AccountsSidebar extends StatelessWidget { - /// Parent navigator - needed for pushing the connect screen, since this - /// widget is mounted inside a modal sheet route. final NavigatorState parentNavigator; final VoidCallback? onSignOut; final String? userName; @@ -64,8 +54,6 @@ class AccountsSidebar extends StatelessWidget { final before = svc.schools.map((s) => s.id).toSet(); final connected = await runAddSchoolFlow(context, parentNavigator); if (connected == null) return; - // The connect flow returns a plugin account id, not a school id; find - // the school that newly appeared in my-schools and make it active. await svc.refresh(); final added = svc.schools.where((s) => !before.contains(s.id)); if (added.isNotEmpty) await svc.setActive(added.first.id); @@ -78,8 +66,6 @@ class AccountsSidebar extends StatelessWidget { final typography = context.theme.typography; return DecoratedBox( - // Opaque surface + trailing border so the dashboard doesn't bleed - // through the sheet. showFSheet does not supply a background itself. decoration: BoxDecoration( color: colors.background, border: Border(right: BorderSide(color: colors.border)), @@ -91,9 +77,6 @@ class AccountsSidebar extends StatelessWidget { final active = svc.active; final isPrivate = AppModeService.instance.isPrivate; - // showFSheet strips MediaQuery.padding, so SafeArea is a no-op here. - // viewPadding survives, so pad the content with it manually - keeps - // the background full-bleed while clearing the status bar / nav bar. final viewPadding = MediaQuery.viewPaddingOf(context); return Padding( padding: EdgeInsets.only( @@ -216,9 +199,6 @@ class AccountsSidebar extends StatelessWidget { } } -/// Rounded-square avatar for a school account, à la Teams' org tiles. Shows the -/// school's backend-supplied logo on a muted surface, falling back to a generic -/// icon - no per-provider asset is bundled in the app. class _SchoolAvatar extends StatelessWidget { static const double size = 40; final String? logoUrl; @@ -295,7 +275,6 @@ class _IdentityHeader extends StatelessWidget { } } -/// Convenience wrapper that opens the sidebar as a left-side modal sheet. Future openAccountsSidebar( BuildContext context, { VoidCallback? onSignOut, diff --git a/lib/ui/dashboard/widgets/add_school_modal.dart b/lib/ui/dashboard/widgets/add_school_modal.dart index 935b5f1..29b90f6 100644 --- a/lib/ui/dashboard/widgets/add_school_modal.dart +++ b/lib/ui/dashboard/widgets/add_school_modal.dart @@ -6,11 +6,6 @@ import '../../../domain/school_system.dart'; import '../../../services/school_systems_service.dart'; import '../../account/unified_connect_screen.dart'; -/// Full add-school flow: fetch the backend's school-system catalog, show the -/// picker, then run the chosen system's connect screen. Returns the new account -/// id, or null if the user cancelled at any step. [navigator] is the navigator -/// the connect screen is pushed onto - pass the dashboard's, not a sheet/dialog -/// navigator that may be torn down mid-flow. Future runAddSchoolFlow( BuildContext context, NavigatorState navigator, @@ -22,16 +17,11 @@ Future runAddSchoolFlow( if (systemKey == null) return null; final system = systems.firstWhere((s) => s.key == systemKey); - // Every system authenticates headlessly through the CRM's unified login - // (POST /api/auth/login → the backend routes to the owning plugin). No WebView. return navigator.push( MaterialPageRoute(builder: (_) => UnifiedConnectScreen(system: system)), ); } -/// Fetches the catalog, showing an error dialog and returning null on failure -/// (or when the backend advertises no systems). The app keeps no offline -/// fallback - the backend is the sole source of truth. Future?> fetchSystemsOrShowError(BuildContext context) async { List systems; try { @@ -68,8 +58,6 @@ Future _showCatalogError(BuildContext context, String message) => ), ); -/// Shows the school-system picker for [systems]. Resolves to the chosen -/// [SchoolSystem.key] or `null` if the user dismissed. Future showAddSchoolModal( BuildContext context, List systems, @@ -114,8 +102,6 @@ class _SystemCard extends StatelessWidget { final logoUrl = OidcConfig.resolveUrl(system.logoUrl); final fallbackIcon = Icon(Icons.school, size: 36, color: colors.mutedForeground); - // Prefer the bundled per-system logo (assets/schoolsystems/.webp); - // fall back to a catalog logoUrl, then a generic icon. final logo = Image.asset( 'assets/schoolsystems/${system.key}.webp', width: 36, diff --git a/lib/ui/documents/documents_page.dart b/lib/ui/documents/documents_page.dart index b12c299..3fd0cca 100644 --- a/lib/ui/documents/documents_page.dart +++ b/lib/ui/documents/documents_page.dart @@ -11,10 +11,6 @@ import '../../services/active_account_service.dart'; import '../../services/api_client.dart'; import '../../services/school_data_service.dart'; -/// Documents screen - mirrors a typical school "personal dossier": files are -/// grouped into folders by their category (report cards / Zeugnisse get their -/// own folder, pinned first). Pull down to fetch fresh files from the provider; -/// tapping a file downloads and opens it. class DocumentsScreen extends StatefulWidget { const DocumentsScreen({super.key}); @@ -25,14 +21,12 @@ class DocumentsScreen extends StatefulWidget { class _DocumentsScreenState extends State { String? _downloadingId; - /// Folder label for a document; report cards land in a dedicated bucket. static String _folderOf(StudentDocumentDto d) { final cat = (d.category ?? '').trim(); if (cat.toLowerCase().contains('zeugnis')) return 'Report cards'; return cat.isEmpty ? 'Other' : cat; } - /// Documents grouped into folders, report cards first, then alphabetical. List>> get _folders { final docs = SchoolDataService.instance.documents; final map = >{}; @@ -51,9 +45,6 @@ class _DocumentsScreenState extends State { return entries; } - /// Pull-to-refresh: trigger a real provider re-fetch (pulls fresh documents - /// from the provider), then reload the local cache. Falls back to a plain cache - /// reload if there's no connected provider account. Future _refresh() async { final active = ActiveAccountService.instance.active; final accountId = active?.pluginAccountId; @@ -74,8 +65,6 @@ class _DocumentsScreenState extends State { await SchoolDataService.instance.refresh(); } - /// Download a document's bytes through the authed Dio and open it with the - /// system viewer. Future _openDocument(StudentDocumentDto doc) async { final id = doc.id; if (id == null) return; @@ -142,7 +131,6 @@ class _DocumentsScreenState extends State { } } -/// An expandable folder containing its document rows. class _FolderTile extends StatefulWidget { final String name; final List files; diff --git a/lib/ui/grades/grades_page.dart b/lib/ui/grades/grades_page.dart index e6ea536..70bf150 100644 --- a/lib/ui/grades/grades_page.dart +++ b/lib/ui/grades/grades_page.dart @@ -5,7 +5,6 @@ import 'package:schuly_api/schuly_api.dart'; import '../../services/school_data_service.dart'; import '../core/grade_color.dart'; -/// Grades tab: live exam grades grouped by class. class GradesPage extends StatelessWidget { const GradesPage({super.key}); @@ -21,11 +20,8 @@ class _GradesView extends StatefulWidget { } class _GradesViewState extends State<_GradesView> { - // Selected semester as a sortable key (year*10 + half); null = auto-pick newest. int? _selectedKey; - // Swiss school year: Aug–Jan counts as the 1st semester, Feb–Jul as the 2nd. - // A null date → key 0 ("Undated"), so dateless grades still show somewhere. static int _semesterKey(Date? d) { if (d == null) return 0; if (d.month >= 8) return d.year * 10 + 1; @@ -33,8 +29,6 @@ class _GradesViewState extends State<_GradesView> { return (d.year - 1) * 10 + 2; } - // A "period" is either a semester (half 1/2) or a whole school year (half 0, - // i.e. year*10). Year periods cover both halves. static bool _isYear(int key) => key != 0 && key % 10 == 0; static String _periodLabel(int key) { @@ -50,7 +44,6 @@ class _GradesViewState extends State<_GradesView> { final svc = SchoolDataService.instance; final myGrades = svc.myGradesByExam; - // Exams I have a grade for, paired with their derived semester. final graded = [ for (final e in svc.exams) if (e.id != null && myGrades.containsKey(e.id)) e, @@ -59,8 +52,6 @@ class _GradesViewState extends State<_GradesView> { return _RefreshableEmpty(onRefresh: svc.refresh, text: 'No grades yet'); } - // Build the period dropdown: each school year (newest first) with a whole-year - // option above its semesters (only when the year actually has two halves). final semKeys = {for (final e in graded) _semesterKey(e.date)}; final yearsDesc = {for (final k in semKeys) k ~/ 10}.toList() ..sort((a, b) => b.compareTo(a)); @@ -74,7 +65,6 @@ class _GradesViewState extends State<_GradesView> { if (halves.length > 1) periods.add(y * 10); // whole-year option periods.addAll(halves); } - // Default to the newest single semester (most focused, current grades). final newestSemester = semKeys.where((k) => !_isYear(k)).fold(0, (m, k) => k > m ? k : m); final selected = (_selectedKey != null && periods.contains(_selectedKey)) ? _selectedKey! @@ -83,7 +73,6 @@ class _GradesViewState extends State<_GradesView> { bool inSelection(int examKey) => _isYear(selected) ? examKey ~/ 10 == selected ~/ 10 : examKey == selected; - // Group the selected period's exams by class, each sorted by date. final classNames = { for (final c in (svc.me?.classes ?? const [])) c.classId: c.className, ...svc.classNameById, diff --git a/lib/ui/home/home_page.dart b/lib/ui/home/home_page.dart index 396f8ba..5fd12b7 100644 --- a/lib/ui/home/home_page.dart +++ b/lib/ui/home/home_page.dart @@ -5,8 +5,6 @@ import 'package:schuly_api/schuly_api.dart'; import '../../services/school_data_service.dart'; import '../core/grade_color.dart'; -/// Glanceable dashboard: today's lessons, upcoming tests, latest grades, and -/// recent absences. Reads everything from [SchoolDataService]. class HomePage extends StatelessWidget { const HomePage({super.key}); @@ -27,7 +25,6 @@ class HomePage extends StatelessWidget { final upcoming = svc.agenda.where((a) => !isHoliday(a) && dayOf(a.date).isAfter(today)).toList() ..sort((a, b) => a.date.compareTo(b.date)); - // Current or upcoming holidays (those whose end-or start-hasn't passed). final holidays = svc.agenda .where((a) => isHoliday(a) && !dayOf(a.endDate ?? a.date).isBefore(today)) .toList() @@ -36,15 +33,10 @@ class HomePage extends StatelessWidget { final myGrades = svc.myGradesByExam; final examById = {for (final e in svc.exams) e.id: e}; final examName = {for (final e in svc.exams) e.id: e.name}; - // Subject per exam, so two same-named exams (e.g. "Semesterprüfung" in Maths - // and Physics) are distinguishable on the card. final classNameById = { for (final c in (svc.me?.classes ?? const [])) c.classId: c.className, ...svc.classNameById, }; - // Only real grades on the latest-grades card (drop ungraded 0 placeholders), - // newest first by the exam date - across semesters a graded exam can be from - // an earlier school year, so date order (not list order) is what's "latest". final recentGrades = myGrades.entries .where((e) => isGraded(e.value.score)) .toList() @@ -168,8 +160,6 @@ class HomePage extends StatelessWidget { } } -/// A plain section: a header label followed by spaced tiles - matching the -/// Grades page (no enclosing card; the tiles carry their own borders). class _Section extends StatelessWidget { final String title; final List tiles; diff --git a/lib/ui/onboarding/onboarding_screen.dart b/lib/ui/onboarding/onboarding_screen.dart index 1e21f5f..21cd60f 100644 --- a/lib/ui/onboarding/onboarding_screen.dart +++ b/lib/ui/onboarding/onboarding_screen.dart @@ -70,8 +70,6 @@ class _OnboardingScreenState extends State { curve: Curves.easeOut, ); - /// Server step: hosted continues immediately; self-hosted is validated and - /// probed (parsing the backend version) before continuing. Future _confirmServer() async { if (_probing) return; if (_server == _Server.hosted) { @@ -114,7 +112,6 @@ class _OnboardingScreenState extends State { _probing = false; _serverOk = 'Connected - Schuly v$version'; }); - // Briefly show the version, then continue to the mode choice. await Future.delayed(const Duration(milliseconds: 900)); if (mounted) _next(); } @@ -204,7 +201,6 @@ class _OnboardingScreenState extends State { ], ), ), - // Page indicator dots. Row( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -221,7 +217,6 @@ class _OnboardingScreenState extends State { ), ], ), - // One bottom button drives every page. Padding( padding: const EdgeInsets.fromLTRB(24, 16, 24, 16), child: SizedBox( @@ -239,7 +234,6 @@ class _OnboardingScreenState extends State { } } -/// A single intro slide: a large badge, a title, and a short blurb. class _IntroPage extends StatelessWidget { final IconData? icon; final String? asset; @@ -258,8 +252,6 @@ class _IntroPage extends StatelessWidget { final colors = context.theme.colors; final typography = context.theme.typography; - // Brand-mark pages (asset) get a solid primary disc with the white logo, - // mirroring the app icon; plain feature pages get a faint disc + tinted icon. final Widget badge = asset != null ? Container( width: 132, @@ -311,8 +303,6 @@ class _IntroPage extends StatelessWidget { } } -/// The server step: hosted Schuly Cloud (default) or a self-hosted backend URL. -/// Pure UI - the parent owns the selection and the Next button validates it. class _ServerPage extends StatelessWidget { final _Server selected; final TextEditingController urlController; @@ -413,8 +403,6 @@ class _ServerPage extends StatelessWidget { } } -/// The decision page: account vs private, as a selectable toggle. Pure UI - the -/// parent owns the selection and the Next button commits it. class _ModeChoicePage extends StatelessWidget { final _Mode selected; final ValueChanged<_Mode> onSelect; @@ -474,8 +462,6 @@ class _ModeChoicePage extends StatelessWidget { } } -/// A selectable card: centered content with a radio indicator in the corner; -/// the selected one gets a primary border and a tinted background. class _ModeCard extends StatelessWidget { final IconData icon; final String title; diff --git a/lib/ui/private/private_connect_flow.dart b/lib/ui/private/private_connect_flow.dart index 2847b40..9ba1dff 100644 --- a/lib/ui/private/private_connect_flow.dart +++ b/lib/ui/private/private_connect_flow.dart @@ -3,9 +3,6 @@ import 'package:flutter/material.dart'; import '../dashboard/widgets/add_school_modal.dart'; import 'private_connect_screen.dart'; -/// Private-mode connect flow: show the backend's school-system picker, then run -/// the generic connect screen for the chosen system (driven entirely by the -/// catalog descriptor). Returns true if a connection was stored on-device. Future runPrivateConnectFlow(BuildContext context) async { final systems = await fetchSystemsOrShowError(context); if (systems == null || !context.mounted) return false; diff --git a/lib/ui/private/private_connect_screen.dart b/lib/ui/private/private_connect_screen.dart index 9837f32..79f94ad 100644 --- a/lib/ui/private/private_connect_screen.dart +++ b/lib/ui/private/private_connect_screen.dart @@ -9,12 +9,6 @@ import '../../services/token_proxy_client.dart'; import '../../services/totp_service.dart'; import '../widgets/dynamic_login_form.dart'; -/// Generic private-mode connect screen. Renders the chosen [system]'s -/// backend-described `loginFields` and connects headlessly - no WebView. The -/// integration shape comes from the catalog `privateAuthStrategy`: `token` -/// systems log in via the stateless credential `/login` (mints a token + -/// context_state); `scrape` systems replay username/password per fetch. -/// Everything is stored on-device only. Pops `true` on success. class PrivateConnectScreen extends StatefulWidget { final SchoolSystem system; const PrivateConnectScreen({required this.system, super.key}); @@ -67,8 +61,6 @@ class _PrivateConnectScreenState extends State { return; } - // `token` systems mint a token via headless credential login; `scrape` - // systems replay credentials on each fetch. if (_system.privateAuthStrategy == 'token') { await _connectToken(baseUrl, name, basePath); } else { @@ -88,8 +80,6 @@ class _PrivateConnectScreenState extends State { String baseUrl, String name, String basePath) async { final email = _form.value('email'); final password = _form.value('password'); - // Accept a typed base32 secret or a scanned otpauth:// URI; normalize to the - // base32 the backend expects, and stash it for on-device generation. final totpSecret = TotpService.secretOf(_form.value('totp')); final res = await TokenProxyClient.instance.login( basePath: basePath, @@ -102,8 +92,6 @@ class _PrivateConnectScreenState extends State { setState(() => _error = res.message ?? 'Login failed'); return; } - // Persist credentials + seed alongside the token so Schuly can silently - // re-login and act as the authenticator. Kept in the device keystore only. await PrivateAccountStore.instance.save(PrivateAccount( systemKey: _system.key, loginMethod: _system.loginMethod, @@ -131,7 +119,6 @@ class _PrivateConnectScreenState extends State { username: _form.value('username'), password: _form.value('password'), ); - // Validate the credentials with one fetch before persisting. await ScrapeProxyClient.instance.data(account); await PrivateAccountStore.instance.save(account); if (mounted) Navigator.of(context).pop(true); diff --git a/lib/ui/settings/settings_screen.dart b/lib/ui/settings/settings_screen.dart index d12e35d..aa427fb 100644 --- a/lib/ui/settings/settings_screen.dart +++ b/lib/ui/settings/settings_screen.dart @@ -12,7 +12,6 @@ import '../../services/private_account_store.dart'; import '../../services/school_data_service.dart'; import '../../services/theme_service.dart'; -/// App settings: appearance, the backend server, and open-source licenses. class SettingsScreen extends StatefulWidget { const SettingsScreen({super.key}); @@ -35,8 +34,6 @@ class _SettingsScreenState extends State { child: ListView( padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), children: [ - // Only account mode has a Schuly (Keycloak) identity to manage; private - // mode keeps everything on-device with no account. if (!AppModeService.instance.isPrivate) ...[ FTileGroup( label: const Text('Account'), @@ -111,9 +108,6 @@ class _SettingsScreenState extends State { ); } - /// Opens the identity provider's account console (Keycloak's `/account`) in the - /// external browser, where the user manages their profile, password and - /// sign-in security. The authority is discovered at runtime, never hardcoded. Future _openAccountConsole() async { Uri? url; try { @@ -130,9 +124,6 @@ class _SettingsScreenState extends State { } } - /// Opens the Keycloak-served profile-picture upload page (`${authority}/avatar/ui`) - /// in the external browser. The page authenticates the user and uploads the image - /// to the avatar SPI, which the OIDC `picture` claim then carries into the app. Future _openProfilePicture() async { Uri? url; try { @@ -155,15 +146,10 @@ class _SettingsScreenState extends State { builder: (ctx, style, animation) => _ServerDialog(animation: animation), ); if (changed != true || !mounted) return; - // The session was cleared while switching backends; drop back to the root - // gate, which re-evaluates against the new server. Navigator.of(context).popUntil((route) => route.isFirst); } } -/// Lets the user point the app at the hosted Schuly Cloud or a self-hosted -/// backend. Saving re-points the HTTP clients, clears the OIDC cache, and signs -/// out - a session and its data belong to one backend. Pops `true` on success. class _ServerDialog extends StatefulWidget { final Animation animation; const _ServerDialog({required this.animation}); @@ -182,7 +168,6 @@ class _ServerDialogState extends State<_ServerDialog> { @override void initState() { super.initState(); - // Live-update the insecure-URL warning as the user types. _urlCtrl.addListener(() => setState(() {})); } @@ -222,7 +207,6 @@ class _ServerDialogState extends State<_ServerDialog> { url = raw; } else { if (!BackendConfig.isCustom) { - // Already on hosted - nothing to change. Navigator.of(context).pop(false); return; } @@ -230,10 +214,7 @@ class _ServerDialogState extends State<_ServerDialog> { } await BackendConfig.setUrl(url); - // The HTTP clients re-point themselves at BackendConfig.url per request; just - // reset the cached OIDC settings so the new backend's authority is re-fetched. OidcConfig.reset(); - // Drop the now wrong-backend session + cached data. await AuthService.signOut(); await PrivateAccountStore.instance.clear(); await ActiveAccountService.instance.clear(); diff --git a/lib/ui/timetable/timetable_page.dart b/lib/ui/timetable/timetable_page.dart index 4096a13..8d7f0f2 100644 --- a/lib/ui/timetable/timetable_page.dart +++ b/lib/ui/timetable/timetable_page.dart @@ -4,8 +4,6 @@ import 'package:schuly_api/schuly_api.dart'; import '../../services/school_data_service.dart'; -/// Timetable: a horizontal day strip (FLineCalendar) and the selected day's -/// agenda entries, colour-coded by type. class TimetablePage extends StatefulWidget { const TimetablePage({super.key}); @@ -30,9 +28,6 @@ class _TimetablePageState extends State { super.dispose(); } - /// Once agenda data is available (it loads after this page mounts), anchor - /// the calendar on the nearest day with entries - unless the user already - /// picked a day. void _autoAnchor() { if (_userPicked || _selected != null) return; final now = DateTime.now(); @@ -72,8 +67,6 @@ class _TimetablePageState extends State { Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: FLineCalendar( - // Remount once when the anchor is first set, so initialScroll - // jumps the strip to the day with data. key: ValueKey(_selected != null), start: DateTime(now.year - 1), end: DateTime(now.year + 2), diff --git a/lib/ui/widgets/dynamic_login_form.dart b/lib/ui/widgets/dynamic_login_form.dart index 7ae7751..e3fd4f4 100644 --- a/lib/ui/widgets/dynamic_login_form.dart +++ b/lib/ui/widgets/dynamic_login_form.dart @@ -4,9 +4,6 @@ import 'package:forui/forui.dart'; import '../../domain/school_system.dart'; import '../authenticator/totp_field_picker.dart'; -/// Owns the text controllers for a set of backend-described login fields and -/// exposes their collected values. The screen creates one from a system's -/// [SchoolSystemLoginField]s and reads [values] on submit. class DynamicLoginFormController { final List fields; final Map _controllers; @@ -19,14 +16,11 @@ class DynamicLoginFormController { TextEditingController controllerFor(String key) => _controllers[key]!; - /// Trimmed value for [key], or empty string if the field isn't present. String value(String key) => _controllers[key]?.text.trim() ?? ''; - /// Collected values keyed by field key. Map get values => {for (final f in fields) f.key: value(f.key)}; - /// First missing required field as an error message, or null if all present. String? validateRequired() { for (final f in fields) { if (f.required && value(f.key).isEmpty) { @@ -43,17 +37,11 @@ class DynamicLoginFormController { } } -/// Renders the login inputs a school system advertises (`loginFields`) so the -/// backend, not the app, decides what the login form shows. class DynamicLoginForm extends StatelessWidget { final DynamicLoginFormController controller; const DynamicLoginForm({required this.controller, super.key}); - /// A field that carries a TOTP secret - rendered as the authenticator picker - /// (select a saved entry or add a new one). Detected by an explicit `totp` - /// type or the conventional `totp` key, so the backend catalog can opt in - /// without the app hardcoding a provider. static bool _isTotp(SchoolSystemLoginField f) => f.type == 'totp' || f.key.toLowerCase() == 'totp'; diff --git a/test/totp_service_test.dart b/test/totp_service_test.dart index 36ef8b1..78825d5 100644 --- a/test/totp_service_test.dart +++ b/test/totp_service_test.dart @@ -63,7 +63,6 @@ void main() { }); test('reports the seconds left in the current 30s window', () { - // epoch second 59 → 59 % 30 == 29 → 1 second until rollover. final at = DateTime.fromMillisecondsSinceEpoch(59000); final result = TotpService.generate(config, at: at)!; expect(result.secondsRemaining, 1);