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
isProfileCompleteflag
📇 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:
-
Prefix Search (Higher Priority): Matches at the beginning of email/name
"joh"matches"john@example.com"or"John Smith"
-
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
isProfileCompleteflag 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:
- SDK Changes: Only modify read operations and core functionality
- Application Changes: Implement write operations in the application layer
- Testing: Ensure all changes are tested with the reference application
- 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