MySched

Service Layer

MySched uses a controller pattern built on Flutter's ChangeNotifier for state management. Each feature area has a dedicated controller that owns business logic, while widgets remain purely presentational.

State Architecture

State is organized into three tiers:

TierScopePersistenceExample
Feature stateSingle screen/flowMemory onlyForm inputs, loading indicators
App stateCross-featureMemory + local DBCurrent section, class list, reminders
Cached stateCross-sessionSharedPreferencesUser profile, theme preference, onboarding

Controller Pattern

Every feature controller extends ChangeNotifier and follows this structure:

Dart
class ScheduleController extends ChangeNotifier {
  final SupabaseService _supabase;
  final SyncService _sync;

  List<ClassItem> _classes = [];
  bool _isLoading = false;
  String? _error;

  // Public getters (read-only)
  List<ClassItem> get classes => List.unmodifiable(_classes);
  bool get isLoading => _isLoading;
  String? get error => _error;

  ScheduleController({
    required SupabaseService supabase,
    required SyncService sync,
  }) : _supabase = supabase, _sync = sync;

  Future<void> loadClasses(String sectionId) async {
    _isLoading = true;
    _error = null;
    notifyListeners();

    try {
      _classes = await _supabase.getClassItems(sectionId);
    } catch (e) {
      _error = e.toString();
    } finally {
      _isLoading = false;
      notifyListeners();
    }
  }

  Future<void> addClass(ClassItem item) async {
    _classes = [..._classes, item];
    notifyListeners();

    // Optimistic update — sync in background
    _sync.enqueue(SyncOperation.insert('class_items', item.toJson()));
  }
}

Key conventions:

  1. Fields are private (_classes), exposed via unmodifiable getters
  2. notifyListeners() is called after every state mutation
  3. Error state is tracked per-controller, not globally
  4. Async operations set _isLoading before and after execution

Service Singletons

Backend communication is abstracted through service classes, initialized once at app startup:

Dart
// App initialization (main.dart)
void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  final supabase = SupabaseService();
  await supabase.initialize();

  final sync = SyncService(supabase: supabase);
  final notifications = NotificationService();
  await notifications.initialize();

  runApp(MySchedApp(
    supabase: supabase,
    sync: sync,
    notifications: notifications,
  ));
}

Core Services

ServiceResponsibilityFile
SupabaseServiceDatabase queries, auth, realtime subscriptionslib/services/supabase_service.dart
SyncServiceOffline queue, conflict resolution, retry logiclib/services/sync_service.dart
NotificationServiceLocal + push notifications, schedulinglib/services/notification_service.dart
RemoteConfigServiceFeature flags, forced update checkslib/services/remote_config_service.dart
AnalyticsServiceEvent tracking, screen views, crash reportinglib/services/analytics_service.dart

ProfileCache

User profile data is cached using ValueNotifier for efficient rebuilds:

Dart
class ProfileCache {
  static final ProfileCache _instance = ProfileCache._();
  factory ProfileCache() => _instance;
  ProfileCache._();

  final ValueNotifier<UserProfile?> profile = ValueNotifier(null);

  Future<void> load() async {
    final prefs = await SharedPreferences.getInstance();
    final json = prefs.getString('user_profile');
    if (json != null) {
      profile.value = UserProfile.fromJson(jsonDecode(json));
    }
  }

  Future<void> update(UserProfile updated) async {
    profile.value = updated;
    final prefs = await SharedPreferences.getInstance();
    await prefs.setString('user_profile', jsonEncode(updated.toJson()));
  }
}

Widgets listen to profile changes with ValueListenableBuilder:

Dart
ValueListenableBuilder<UserProfile?>(
  valueListenable: ProfileCache().profile,
  builder: (context, profile, child) {
    return Text(profile?.displayName ?? 'Guest');
  },
)

Offline Queue

The SyncService maintains a persistent queue of operations that failed due to connectivity:

Dart
class SyncService {
  final Queue<SyncOperation> _pendingOps = Queue();
  final SupabaseService _supabase;
  bool _isSyncing = false;

  void enqueue(SyncOperation op) {
    _pendingOps.add(op);
    _persistQueue();       // Save to local storage
    _attemptSync();        // Try immediately
  }

  Future<void> _attemptSync() async {
    if (_isSyncing || _pendingOps.isEmpty) return;
    _isSyncing = true;

    while (_pendingOps.isNotEmpty) {
      final op = _pendingOps.first;
      try {
        await _supabase.execute(op);
        _pendingOps.removeFirst();
        _persistQueue();
      } catch (e) {
        // Exponential backoff on failure
        await Future.delayed(op.nextRetryDelay);
        op.incrementRetry();
        if (op.retryCount > op.maxRetries) {
          _pendingOps.removeFirst(); // Drop after max retries
          _persistQueue();
        }
        break;
      }
    }

    _isSyncing = false;
  }
}

Queue behavior:

  • Operations are persisted to SharedPreferences as JSON
  • Retry uses exponential backoff (1s, 2s, 4s, 8s, up to 60s)
  • Maximum 10 retries before an operation is dropped
  • Connectivity changes trigger _attemptSync() automatically
  • Conflict resolution: server timestamp wins (last-write-wins)

Two-Tier Settings

User settings use a two-tier system:

  1. LocalSharedPreferences for instant reads (theme, locale, notification preferences)
  2. CloudUserSettingsService syncs settings to Supabase for cross-device consistency
Dart
class UserSettingsService {
  Future<void> setSetting(String key, dynamic value) async {
    // Write locally first (instant)
    final prefs = await SharedPreferences.getInstance();
    await prefs.setString(key, jsonEncode(value));

    // Sync to cloud (background)
    await _supabase.upsertSetting(key, value);
  }

  Future<T?> getSetting<T>(String key) async {
    // Read from local cache first
    final prefs = await SharedPreferences.getInstance();
    final local = prefs.getString(key);
    if (local != null) return jsonDecode(local) as T;

    // Fall back to cloud
    return await _supabase.getSetting<T>(key);
  }
}