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:
| Tier | Scope | Persistence | Example |
|---|---|---|---|
| Feature state | Single screen/flow | Memory only | Form inputs, loading indicators |
| App state | Cross-feature | Memory + local DB | Current section, class list, reminders |
| Cached state | Cross-session | SharedPreferences | User 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:
- Fields are private (
_classes), exposed via unmodifiable getters notifyListeners()is called after every state mutation- Error state is tracked per-controller, not globally
- Async operations set
_isLoadingbefore 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
| Service | Responsibility | File |
|---|---|---|
SupabaseService | Database queries, auth, realtime subscriptions | lib/services/supabase_service.dart |
SyncService | Offline queue, conflict resolution, retry logic | lib/services/sync_service.dart |
NotificationService | Local + push notifications, scheduling | lib/services/notification_service.dart |
RemoteConfigService | Feature flags, forced update checks | lib/services/remote_config_service.dart |
AnalyticsService | Event tracking, screen views, crash reporting | lib/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
SharedPreferencesas 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:
- Local —
SharedPreferencesfor instant reads (theme, locale, notification preferences) - Cloud —
UserSettingsServicesyncs 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);
}
}