Circle SDK

A Flutter SDK for Circle Platform that provides authentication and contact management capabilities using Supabase as the backend. The SDK follows a read-only pattern for data operations, with write operations (Create, Update, Delete) implemented in the application layer for better control and customization.

Architecture Overview

The Circle SDK is designed with a clear separation of concerns:

  • SDK Responsibilities: Authentication, data reading, user discovery, connection management
  • Application Responsibilities: Profile management, contact CRUD operations, business logic
  • State Management: Seamless integration with Riverpod for reactive state management
  • Navigation: Router integration for auth-based navigation flows

Features

🔐 Authentication

  • Email OTP-based authentication (passwordless)
  • Session persistence with automatic state management
  • User profile retrieval (read-only)
  • Auth state streaming for reactive UI updates
  • Profile completion tracking with isProfileComplete flag

📇 Contact Management (Read-Only)

  • Retrieve contacts with pagination and search
  • Search contacts by name or email with intelligent priority matching
  • User discovery - search for registered users to add as contacts
  • User existence checking for contact validation
  • Advanced search algorithms with prefix and contains matching

🛠️ Developer Experience

  • Type-safe models with JSON serialization
  • Comprehensive error handling with typed exceptions
  • Connection status monitoring for offline scenarios
  • Riverpod integration for state management
  • GoRouter integration for navigation flows

Getting Started

Installation

Add the SDK to your pubspec.yaml:

dependencies:
  circle_sdk_test:
    path: ../packages/circle_sdk  # For local development
    # git: https://github.com/yourgitrepo/circle_sdk_test.git  # For Git repository
  
  # Required dependencies for integration
  supabase_flutter: ^2.1.0
  hooks_riverpod: ^2.4.9
  go_router: ^12.1.3

Initialize Supabase

Before using the SDK, initialize Supabase in your main.dart:

import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:circle_sdk_test/circle_sdk_test.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  
  // Initialize Supabase
  await Supabase.initialize(
    url: 'YOUR_SUPABASE_URL',
    anonKey: 'YOUR_SUPABASE_ANON_KEY',
    debug: true, // Enable debug logging
  );
  
  runApp(const ProviderScope(
    child: MyApp(),
  ));
}

Integration with Riverpod

The SDK is designed to work seamlessly with Riverpod for state management:

Setting Up Providers

// auth_provider.dart
import 'package:circle_sdk_test/circle_sdk_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';

/// Provider for the Supabase client
final supabaseClientProvider = Provider<SupabaseClient>((ref) {
  return Supabase.instance.client;
});

/// Provider for the auth client from Circle SDK
final authClientProvider = Provider<AuthClient>((ref) {
  final supabase = ref.watch(supabaseClientProvider);
  return AuthClient(supabase: supabase);
});

/// Provider for the current authenticated user state
final authStateProvider = StreamProvider<CircleUser?>((ref) {
  final authClient = ref.watch(authClientProvider);
  return authClient.userChanges;
});

/// Provider for the current user profile
final userProfileProvider = FutureProvider<UserProfile?>((ref) async {
  final authClient = ref.watch(authClientProvider);
  return authClient.getUserProfile();
});

Contact Providers

// contacts_provider.dart
import 'package:circle_sdk_test/circle_sdk_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

/// Provider for the contact client from Circle SDK
final contactClientProvider = Provider<ContactClient>((ref) {
  final supabase = ref.watch(supabaseClientProvider);
  return ContactClient(supabase: supabase);
});

/// Provider for contacts list with pagination
final contactsProvider = FutureProvider.family<List<Contact>, ContactsParams>(
  (ref, params) async {
    final contactClient = ref.watch(contactClientProvider);
    return contactClient.getContacts(
      page: params.page,
      pageSize: params.pageSize,
      searchQuery: params.searchQuery,
    );
  },
);

/// Provider for user search (finding users to add as contacts)
final userSearchProvider = FutureProvider.family<List<UserProfile>, String>(
  (ref, query) async {
    if (query.isEmpty) return [];
    
    final contactClient = ref.watch(contactClientProvider);
    return contactClient.searchUsers(query, limit: 10);
  },
);

Authentication Usage

Basic Authentication Flow

class AuthRepository {
  final AuthClient _authClient;
  final SupabaseClient _supabase;

  AuthRepository(this._authClient, this._supabase);

  /// Send OTP to user's email
  Future<bool> signInWithEmailOtp({required String email}) {
    return _authClient.signInWithEmailOtp(
      email: email,
      shouldCreateUser: true,
    );
  }

  /// Verify OTP and get user
  Future<CircleUser> verifyOtp({
    required String email,
    required String otp,
  }) {
    return _authClient.verifyOtp(email: email, otp: otp);
  }

  /// Sign out current user
  Future<void> signOut() {
    return _authClient.signOut();
  }
}

Profile Management (Application Layer)

Profile creation and updates are implemented in your application, not in the SDK:

/// Create or update user profile in your application
Future<UserProfile> createOrUpdateUserProfile({
  required String name,
  String? avatarUrl,
  String? bio,
  String? phoneNumber,
  String? jobTitle,
  String? company,
  Map<String, dynamic>? metadata,
}) async {
  final supabase = Supabase.instance.client;
  final currentUserId = supabase.auth.currentUser?.id;
  
  if (currentUserId == null) {
    throw Exception('Not authenticated');
  }

  // Always mark profile as complete when updating
  final updatedMetadata = {...(metadata ?? {})};
  updatedMetadata['isProfileComplete'] = true;

  // Check if user profile exists
  final response = await supabase
      .from('users')
      .select()
      .eq('id', currentUserId);

  Map<String, dynamic> userData;

  if (response.isEmpty) {
    // Create new profile
    userData = {
      'id': currentUserId,
      'email': supabase.auth.currentUser!.email,
      'name': name,
      'avatar_url': avatarUrl,
      'bio': bio,
      'phone_number': phoneNumber,
      'job_title': jobTitle,
      'company': company,
      'metadata': updatedMetadata,
    };

    await supabase.from('users').insert(userData);
  } else {
    // Update existing profile
    userData = {
      'name': name,
      'metadata': updatedMetadata,
    };
    
    if (avatarUrl != null) userData['avatar_url'] = avatarUrl;
    if (bio != null) userData['bio'] = bio;
    if (phoneNumber != null) userData['phone_number'] = phoneNumber;
    if (jobTitle != null) userData['job_title'] = jobTitle;
    if (company != null) userData['company'] = company;

    await supabase
        .from('users')
        .update(userData)
        .eq('id', currentUserId);
  }

  // IMPORTANT: Refresh the auth client state
  final authClient = AuthClient(supabase: supabase);
  await authClient.refreshUserState();

  // Return the updated profile
  final updatedResponse = await supabase
      .from('users')
      .select()
      .eq('id', currentUserId)
      .single();

  return UserProfile.fromJson(updatedResponse);
}

Router Integration

Integrate with GoRouter for auth-based navigation:

final appRouterProvider = Provider<GoRouter>((ref) {
  return GoRouter(
    initialLocation: '/login',
    redirect: (context, state) async {
      final authState = ref.read(authStateProvider);
      final currentLocation = state.matchedLocation;
      
      if (authState.isLoading) return null;
      
      if (authState.hasError) {
        return currentLocation != '/login' ? '/login' : null;
      }
      
      final currentUser = authState.valueOrNull;
      final isLoggedIn = currentUser != null;
      
      if (!isLoggedIn) {
        return currentLocation != '/login' ? '/login' : null;
      }
      
      // Handle profile completion flow
      if (currentLocation == '/login') {
        return currentUser.isProfileComplete ? '/contacts' : '/onboarding';
      }
      
      if (!currentUser.isProfileComplete) {
        return currentLocation != '/onboarding' ? '/onboarding' : null;
      }
      
      return null;
    },
    refreshListenable: RiverpodAuthStateNotifier(ref),
    routes: [
      GoRoute(path: '/login', builder: (context, state) => const LoginPage()),
      GoRoute(path: '/onboarding', builder: (context, state) => const OnboardingPage()),
      GoRoute(path: '/contacts', builder: (context, state) => const ContactsPage()),
    ],
  );
});

Contact Management Usage

Reading Contacts (SDK)

class ContactsPage extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final contactsAsync = ref.watch(contactsProvider(ContactsParams(
      page: 1,
      pageSize: 20,
      searchQuery: searchQuery,
    )));

    return contactsAsync.when(
      data: (contacts) => ListView.builder(
        itemCount: contacts.length,
        itemBuilder: (context, index) {
          final contact = contacts[index];
          return ListTile(
            title: Text(contact.name ?? contact.email),
            subtitle: Text(contact.email),
            trailing: contact.isRegistered 
              ? const Icon(Icons.verified_user)
              : const Icon(Icons.person_outline),
          );
        },
      ),
      loading: () => const CircularProgressIndicator(),
      error: (error, stack) => Text('Error: $error'),
    );
  }
}

Creating Contacts (Application Layer)

class ContactsRepository {
  final ContactClient _contactClient;
  final SupabaseClient _supabase;

  ContactsRepository(this._contactClient, this._supabase);

  /// Create a new contact (implemented in app, not SDK)
  Future<Contact> createContact({
    required String email,
    String? name,
    String? avatarUrl,
    Map<String, dynamic>? metadata,
  }) async {
    final currentUserId = _supabase.auth.currentUser?.id;
    if (currentUserId == null) {
      throw Exception('Not authenticated');
    }
    
    // Check if contact already exists
    final existingContact = await _supabase
        .from('contacts')
        .select()
        .eq('user_id', currentUserId)
        .eq('email', email)
        .maybeSingle();
    
    if (existingContact != null) {
      throw Exception('Contact already exists');
    }

    // Use SDK to search for registered user
    final foundUsers = await _contactClient.searchUsers(email);
    final matchingUser = foundUsers
        .where((user) => user.email.toLowerCase() == email.toLowerCase())
        .firstOrNull;
    
    final isRegistered = matchingUser != null;
    final contactUserId = isRegistered ? matchingUser.id : null;

    // Create the contact
    final contactId = const Uuid().v4();
    final contactData = {
      'id': contactId,
      'user_id': currentUserId,
      'contact_user_id': contactUserId,
      'email': email,
      'name': name ?? (matchingUser?.name ?? ''),
      'avatar_url': avatarUrl ?? matchingUser?.avatarUrl,
      'is_registered': isRegistered,
      'metadata': metadata,
    };

    await _supabase.from('contacts').insert(contactData);
    
    // Return the created contact
    final response = await _supabase
        .from('contacts')
        .select()
        .eq('id', contactId)
        .single();
    
    return Contact.fromJson(response);
  }

  /// Update contact (implemented in app, not SDK)
  Future<Contact> updateContact({
    required String contactId,
    String? name,
    String? avatarUrl,
    Map<String, dynamic>? metadata,
  }) async {
    final currentUserId = _supabase.auth.currentUser?.id;
    if (currentUserId == null) {
      throw Exception('Not authenticated');
    }
    
    final contactData = {
      'name': name,
      'avatar_url': avatarUrl,
      'metadata': metadata,
    };

    await _supabase
        .from('contacts')
        .update(contactData)
        .eq('id', contactId)
        .eq('user_id', currentUserId);
    
    final response = await _supabase
        .from('contacts')
        .select()
        .eq('id', contactId)
        .single();
    
    return Contact.fromJson(response);
  }

  /// Delete contact (implemented in app, not SDK)
  Future<void> deleteContact(String contactId) async {
    final currentUserId = _supabase.auth.currentUser?.id;
    if (currentUserId == null) {
      throw Exception('Not authenticated');
    }
    
    await _supabase
        .from('contacts')
        .delete()
        .eq('id', contactId)
        .eq('user_id', currentUserId);
  }
}

Search Functionality

The Circle SDK provides intelligent search capabilities with priority-based matching:

Contact Search (Within User's Contacts)

// Search within user's existing contacts
final contacts = await contactClient.searchContacts('john');

User Search (For Adding New Contacts)

// Search across all registered users to find people to add
final users = await contactClient.searchUsers('john', limit: 10);

// Use in UI with debouncing
class AddContactDialog extends HookConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final searchQuery = useState('');
    final debouncedQuery = useDebounced(searchQuery.value, const Duration(milliseconds: 300));
    
    final userSearchAsync = ref.watch(userSearchProvider(debouncedQuery));
    
    return Column(
      children: [
        TextField(
          onChanged: (value) => searchQuery.value = value,
          decoration: const InputDecoration(
            hintText: 'Search for users...',
            prefixIcon: Icon(Icons.search),
          ),
        ),
        Expanded(
          child: userSearchAsync.when(
            data: (users) => ListView.builder(
              itemCount: users.length,
              itemBuilder: (context, index) {
                final user = users[index];
                return ListTile(
                  title: Text(user.name),
                  subtitle: Text(user.email),
                  onTap: () => _addUserAsContact(user),
                );
              },
            ),
            loading: () => const CircularProgressIndicator(),
            error: (error, stack) => Text('Error: $error'),
          ),
        ),
      ],
    );
  }
}

Search Algorithm

The SDK implements a two-tier search priority system:

  1. Prefix Search (Higher Priority): Matches at the beginning of email/name

    • "joh" matches "john@example.com" or "John Smith"
  2. Contains Search (Lower Priority): Matches anywhere in email/name

    • "joh" matches "sajohnson@example.com" if no prefix matches found

Data Models

CircleUser

class CircleUser {
  final String id;
  final String email;
  final String? name;
  final String? avatarUrl;
  final String? bio;
  final Map<String, dynamic>? metadata;
  final bool isProfileComplete;  // Key field for navigation logic
  final DateTime createdAt;
  final DateTime updatedAt;
}

UserProfile

class UserProfile {
  final String id;
  final String email;
  final String name;  // Required for profiles
  final String? avatarUrl;
  final String? bio;
  final String? phoneNumber;
  final String? jobTitle;
  final String? company;
  final Map<String, dynamic>? metadata;
  final DateTime createdAt;
  final DateTime updatedAt;
}

Contact

class Contact {
  final String id;
  final String userId;           // Owner of the contact
  final String? contactUserId;   // ID if contact is a registered user
  final String email;
  final String? name;
  final String? avatarUrl;
  final bool isRegistered;       // Whether contact is a registered user
  final Map<String, dynamic>? metadata;
  final DateTime createdAt;
  final DateTime updatedAt;
}

Error Handling

The SDK provides typed exceptions for better error handling:

AuthException

try {
  await authClient.verifyOtp(email: email, otp: otp);
} on AuthException catch (e) {
  switch (e.code) {
    case 'invalid_otp':
      showError('Invalid verification code');
      break;
    case 'otp_expired':
      showError('Verification code expired');
      break;
    case 'network_error':
      showError('Network error. Please check your connection.');
      break;
    default:
      showError(e.message);
  }
}

ContactException

try {
  final contacts = await contactClient.getContacts();
} on ContactException catch (e) {
  switch (e.code) {
    case 'unauthorized':
      redirectToLogin();
      break;
    case 'network_error':
      showError('Network error');
      break;
    default:
      showError(e.message);
  }
}

Database Setup

Your Supabase database should have the following structure:

Users Table

CREATE TABLE users (
  id UUID PRIMARY KEY REFERENCES auth.users(id),
  email TEXT NOT NULL UNIQUE,
  name TEXT,
  avatar_url TEXT,
  bio TEXT,
  phone_number TEXT,
  job_title TEXT,
  company TEXT,
  metadata JSONB,
  created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
  updated_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
);

-- Row Level Security
CREATE POLICY "Users can view their own data" ON users
  FOR SELECT USING (auth.uid() = id);

CREATE POLICY "Users can update their own data" ON users
  FOR UPDATE USING (auth.uid() = id);

CREATE POLICY "Users can insert their own data" ON users
  FOR INSERT WITH CHECK (auth.uid() = id);

-- Indexes for search performance
CREATE INDEX idx_users_email ON users USING btree (email);
CREATE INDEX idx_users_name ON users USING btree (name);

-- Enable trigram extension for fuzzy search
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_users_email_trigram ON users USING gin (email gin_trgm_ops);
CREATE INDEX idx_users_name_trigram ON users USING gin (name gin_trgm_ops);

Contacts Table

CREATE TABLE contacts (
  id UUID PRIMARY KEY,
  user_id UUID NOT NULL REFERENCES auth.users(id),
  contact_user_id UUID REFERENCES users(id),
  email TEXT NOT NULL,
  name TEXT,
  avatar_url TEXT,
  is_registered BOOLEAN NOT NULL DEFAULT false,
  metadata JSONB,
  created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
  updated_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
  UNIQUE(user_id, email)
);

-- Row Level Security
CREATE POLICY "Users can view their own contacts" ON contacts
  FOR SELECT USING (auth.uid() = user_id);

CREATE POLICY "Users can insert their own contacts" ON contacts
  FOR INSERT WITH CHECK (auth.uid() = user_id);

CREATE POLICY "Users can update their own contacts" ON contacts
  FOR UPDATE USING (auth.uid() = user_id);

CREATE POLICY "Users can delete their own contacts" ON contacts
  FOR DELETE USING (auth.uid() = user_id);

-- Indexes for performance
CREATE INDEX idx_contacts_user_id ON contacts USING btree (user_id);
CREATE INDEX idx_contacts_email ON contacts USING btree (email);
CREATE INDEX idx_contacts_name ON contacts USING btree (name);
CREATE INDEX idx_contacts_email_trigram ON contacts USING gin (email gin_trgm_ops);
CREATE INDEX idx_contacts_name_trigram ON contacts USING gin (name gin_trgm_ops);

Best Practices

1. State Management

  • Use Riverpod providers for all SDK interactions
  • Invalidate providers when data changes
  • Handle loading and error states properly

2. Navigation

  • Implement auth-based routing with GoRouter
  • Use the isProfileComplete flag for onboarding flows
  • Handle auth state changes reactively

3. Error Handling

  • Always catch typed exceptions
  • Provide user-friendly error messages
  • Implement retry mechanisms for network errors

4. Performance

  • Use pagination for large contact lists
  • Implement debouncing for search inputs
  • Cache frequently accessed data with Riverpod

5. Security

  • Never store sensitive data in metadata
  • Validate all user inputs before database operations
  • Use Row Level Security policies in Supabase

Example Implementation

For a complete example of how to integrate the Circle SDK, refer to the circle_platform application in this repository, which demonstrates:

  • Full authentication flow with OTP
  • Profile completion and management
  • Contact CRUD operations
  • Search functionality
  • Router integration
  • State management with Riverpod
  • Error handling and loading states

Contributing

When contributing to the Circle SDK:

  1. SDK Changes: Only modify read operations and core functionality
  2. Application Changes: Implement write operations in the application layer
  3. Testing: Ensure all changes are tested with the reference application
  4. Documentation: Update this README for any API changes

License

This project is licensed under the MIT License - see the LICENSE file for details.

Libraries

circle_sdk_test
Circle Platform SDK