- Share extension URL capture (Android/iOS intent handling)
- Article library view with date-based grouping
- Article detail screen with markdown rendering, progress tracking
- AI summary cards (key points, topics, image analysis)
- Daily digest generation with cross-article insights
- Supabase backend (PostgreSQL, Edge Functions, RLS policies)
- Real-time sync via Riverpod streams
- Processing queue with retry logic
- Settings screen (10 TODOs - all toggles are placeholders)
- User authentication (currently anonymous only)
- Search functionality
- Archive view
- Data export
- Push notifications (Firebase setup incomplete)
- No automated tests exist - no
test/directory - Manual testing possible once Supabase is configured
The user_settings table in Supabase already has all required fields:
CREATE TABLE public.user_settings (
id UUID PRIMARY KEY,
user_id UUID REFERENCES auth.users(id),
digest_time TIME DEFAULT '08:00:00',
timezone TEXT DEFAULT 'America/Los_Angeles',
analyze_images BOOLEAN DEFAULT true,
include_comments BOOLEAN DEFAULT true,
push_notifications BOOLEAN DEFAULT true,
fcm_token TEXT,
created_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ
);File: lib/core/models/models.dart
Add at the end of the file:
/// User preferences and settings
class UserSettings {
final String userId;
final TimeOfDay digestTime;
final String timezone;
final bool analyzeImages;
final bool includeComments;
final bool pushNotifications;
final String? fcmToken;
UserSettings({
required this.userId,
this.digestTime = const TimeOfDay(hour: 8, minute: 0),
this.timezone = 'America/Los_Angeles',
this.analyzeImages = true,
this.includeComments = true,
this.pushNotifications = true,
this.fcmToken,
});
factory UserSettings.fromJson(Map<String, dynamic> json) {
// Parse TIME string "08:00:00" to TimeOfDay
TimeOfDay parseTime(String? timeStr) {
if (timeStr == null) return const TimeOfDay(hour: 8, minute: 0);
final parts = timeStr.split(':');
return TimeOfDay(
hour: int.parse(parts[0]),
minute: int.parse(parts[1]),
);
}
return UserSettings(
userId: json['user_id'] as String,
digestTime: parseTime(json['digest_time'] as String?),
timezone: json['timezone'] as String? ?? 'America/Los_Angeles',
analyzeImages: json['analyze_images'] as bool? ?? true,
includeComments: json['include_comments'] as bool? ?? true,
pushNotifications: json['push_notifications'] as bool? ?? true,
fcmToken: json['fcm_token'] as String?,
);
}
Map<String, dynamic> toJson() => {
'user_id': userId,
'digest_time': '${digestTime.hour.toString().padLeft(2, '0')}:${digestTime.minute.toString().padLeft(2, '0')}:00',
'timezone': timezone,
'analyze_images': analyzeImages,
'include_comments': includeComments,
'push_notifications': pushNotifications,
'fcm_token': fcmToken,
};
UserSettings copyWith({
String? userId,
TimeOfDay? digestTime,
String? timezone,
bool? analyzeImages,
bool? includeComments,
bool? pushNotifications,
String? fcmToken,
}) {
return UserSettings(
userId: userId ?? this.userId,
digestTime: digestTime ?? this.digestTime,
timezone: timezone ?? this.timezone,
analyzeImages: analyzeImages ?? this.analyzeImages,
includeComments: includeComments ?? this.includeComments,
pushNotifications: pushNotifications ?? this.pushNotifications,
fcmToken: fcmToken ?? this.fcmToken,
);
}
}Don't forget to add import 'package:flutter/material.dart'; at the top for TimeOfDay.
File: lib/core/services/supabase_service.dart
Add these methods to the SupabaseService class:
// ============ Settings ============
/// Get user settings
Future<UserSettings> getUserSettings() async {
final response = await _client
.from('user_settings')
.select()
.eq('user_id', userId!)
.maybeSingle();
if (response == null) {
// Settings should be auto-created by DB trigger, but handle edge case
final created = await _client.from('user_settings').insert({
'user_id': userId,
}).select().single();
return UserSettings.fromJson(created);
}
return UserSettings.fromJson(response);
}
/// Update a single setting
Future<void> updateSetting(String key, dynamic value) async {
await _client.from('user_settings').update({
key: value,
'updated_at': DateTime.now().toIso8601String(),
}).eq('user_id', userId!);
}
/// Update digest time
Future<void> updateDigestTime(TimeOfDay time) async {
final timeString = '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}:00';
await updateSetting('digest_time', timeString);
}
/// Update FCM token for push notifications
Future<void> updateFcmToken(String? token) async {
await updateSetting('fcm_token', token);
}
/// Get all articles for export (including archived)
Future<List<Article>> getAllArticlesForExport() async {
final response = await _client
.from('articles')
.select()
.eq('user_id', userId!)
.order('created_at', ascending: false);
return (response as List).map((e) => Article.fromJson(e)).toList();
}
/// Get all digests for export
Future<List<DailyDigest>> getAllDigestsForExport() async {
final response = await _client
.from('digests')
.select()
.eq('user_id', userId!)
.order('date', ascending: false);
return (response as List).map((e) => DailyDigest.fromJson(e)).toList();
}
/// Delete all user data (articles and digests)
Future<void> clearAllData() async {
await _client.from('articles').delete().eq('user_id', userId!);
await _client.from('digests').delete().eq('user_id', userId!);
}
/// Stream of archived articles
Stream<List<Article>> watchArchivedArticles() {
return _client
.from('articles')
.stream(primaryKey: ['id'])
.eq('user_id', userId!)
.order('created_at', ascending: false)
.map((data) => data
.map((e) => Article.fromJson(e))
.where((a) => a.isArchived)
.toList());
}
/// Restore archived article
Future<void> restoreArticle(String id) async {
await _client.from('articles').update({
'is_archived': false,
}).eq('id', id);
}New File: lib/features/settings/providers/settings_providers.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/services/supabase_service.dart';
import '../../../core/models/models.dart';
import '../../articles/providers/article_providers.dart';
/// User settings provider
final userSettingsProvider = AsyncNotifierProvider<UserSettingsNotifier, UserSettings>(() {
return UserSettingsNotifier();
});
class UserSettingsNotifier extends AsyncNotifier<UserSettings> {
@override
Future<UserSettings> build() async {
return ref.read(supabaseServiceProvider).getUserSettings();
}
Future<void> updateDigestTime(TimeOfDay time) async {
final oldState = state.value!;
state = AsyncData(oldState.copyWith(digestTime: time));
try {
await ref.read(supabaseServiceProvider).updateDigestTime(time);
} catch (e) {
state = AsyncData(oldState);
rethrow;
}
}
Future<void> togglePushNotifications(bool value) async {
final oldState = state.value!;
state = AsyncData(oldState.copyWith(pushNotifications: value));
try {
await ref.read(supabaseServiceProvider).updateSetting('push_notifications', value);
} catch (e) {
state = AsyncData(oldState);
rethrow;
}
}
Future<void> toggleAnalyzeImages(bool value) async {
final oldState = state.value!;
state = AsyncData(oldState.copyWith(analyzeImages: value));
try {
await ref.read(supabaseServiceProvider).updateSetting('analyze_images', value);
} catch (e) {
state = AsyncData(oldState);
rethrow;
}
}
Future<void> toggleIncludeComments(bool value) async {
final oldState = state.value!;
state = AsyncData(oldState.copyWith(includeComments: value));
try {
await ref.read(supabaseServiceProvider).updateSetting('include_comments', value);
} catch (e) {
state = AsyncData(oldState);
rethrow;
}
}
}
/// Provider for archived articles stream
final archivedArticlesProvider = StreamProvider<List<Article>>((ref) {
return ref.read(supabaseServiceProvider).watchArchivedArticles();
});File: lib/core/config/env.dart
Add these constants:
// Legal URLs (update with your actual URLs)
static const String privacyPolicyUrl = 'https://yourapp.com/privacy';
static const String termsOfServiceUrl = 'https://yourapp.com/terms';New File: lib/features/settings/screens/archive_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/theme/app_theme.dart';
import '../../articles/providers/article_providers.dart';
import '../providers/settings_providers.dart';
class ArchiveScreen extends ConsumerWidget {
const ArchiveScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final archivedArticles = ref.watch(archivedArticlesProvider);
return Scaffold(
body: CustomScrollView(
slivers: [
SliverAppBar(
floating: true,
title: const Text('Archived Articles'),
),
archivedArticles.when(
data: (articles) {
if (articles.isEmpty) {
return SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.archive_outlined,
size: 64,
color: context.mutedTextColor,
),
const SizedBox(height: 16),
Text(
'No archived articles',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: context.mutedTextColor,
),
),
const SizedBox(height: 8),
Text(
'Archived articles will appear here',
style: TextStyle(color: context.mutedTextColor),
),
],
),
),
);
}
return SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final article = articles[index];
return ListTile(
leading: article.imageUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
article.imageUrl!,
width: 56,
height: 56,
fit: BoxFit.cover,
),
)
: Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: context.surfaceColor,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
Icons.article_outlined,
color: context.mutedTextColor,
),
),
title: Text(
article.title ?? 'Untitled',
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
article.siteName ?? Uri.parse(article.url).host,
style: TextStyle(color: context.mutedTextColor),
),
trailing: PopupMenuButton(
itemBuilder: (context) => [
const PopupMenuItem(
value: 'restore',
child: ListTile(
leading: Icon(Icons.unarchive_outlined),
title: Text('Restore'),
contentPadding: EdgeInsets.zero,
),
),
const PopupMenuItem(
value: 'delete',
child: ListTile(
leading: Icon(Icons.delete_outline, color: Colors.red),
title: Text('Delete', style: TextStyle(color: Colors.red)),
contentPadding: EdgeInsets.zero,
),
),
],
onSelected: (value) async {
final service = ref.read(supabaseServiceProvider);
if (value == 'restore') {
await service.restoreArticle(article.id);
} else if (value == 'delete') {
await service.deleteArticle(article.id);
}
},
),
onTap: () => context.push('/article/${article.id}'),
);
},
childCount: articles.length,
),
);
},
loading: () => const SliverFillRemaining(
child: Center(child: CircularProgressIndicator()),
),
error: (error, _) => SliverFillRemaining(
child: Center(child: Text('Error: $error')),
),
),
],
),
);
}
}File: lib/core/router/app_router.dart
Add the archive route. First import:
import '../../features/settings/screens/archive_screen.dart';Then add this route alongside your other routes:
GoRoute(
path: '/archive',
name: 'archive',
builder: (context, state) => const ArchiveScreen(),
),File: lib/features/settings/screens/settings_screen.dart
Replace the entire file with the updated implementation (see below for key changes):
Add imports at the top:
import 'dart:convert';
import 'package:url_launcher/url_launcher.dart';
import 'package:go_router/go_router.dart';
import 'package:share_plus/share_plus.dart'; // Add to pubspec if needed
import 'package:path_provider/path_provider.dart';
import 'dart:io';
import '../../../core/config/env.dart';
import '../providers/settings_providers.dart';Key changes to the widget:
- Watch settings provider at the start of build:
final settingsAsync = ref.watch(userSettingsProvider);- Digest Time (line 30):
_SettingsTile(
icon: Icons.schedule_outlined,
title: 'Digest Time',
subtitle: settingsAsync.when(
data: (s) => '${s.digestTime.hourOfPeriod}:${s.digestTime.minute.toString().padLeft(2, '0')} ${s.digestTime.period == DayPeriod.am ? 'AM' : 'PM'}',
loading: () => 'Loading...',
error: (_, __) => '8:00 AM',
),
onTap: () async {
final settings = ref.read(userSettingsProvider).value;
if (settings == null) return;
final picked = await showTimePicker(
context: context,
initialTime: settings.digestTime,
);
if (picked != null) {
ref.read(userSettingsProvider.notifier).updateDigestTime(picked);
}
},
),- Push Notifications (line 40):
trailing: settingsAsync.when(
data: (s) => Switch(
value: s.pushNotifications,
onChanged: (value) {
ref.read(userSettingsProvider.notifier).togglePushNotifications(value);
},
),
loading: () => const SizedBox(width: 48, height: 24, child: LinearProgressIndicator()),
error: (_, __) => Switch(value: false, onChanged: null),
),- Analyze Images (line 54):
trailing: settingsAsync.when(
data: (s) => Switch(
value: s.analyzeImages,
onChanged: (value) {
ref.read(userSettingsProvider.notifier).toggleAnalyzeImages(value);
},
),
loading: () => const SizedBox(width: 48, height: 24, child: LinearProgressIndicator()),
error: (_, __) => Switch(value: true, onChanged: null),
),- Include Comments (line 65):
trailing: settingsAsync.when(
data: (s) => Switch(
value: s.includeComments,
onChanged: (value) {
ref.read(userSettingsProvider.notifier).toggleIncludeComments(value);
},
),
loading: () => const SizedBox(width: 48, height: 24, child: LinearProgressIndicator()),
error: (_, __) => Switch(value: true, onChanged: null),
),- Auth Flow (line 77):
onTap: () {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Sign In'),
content: const Text('Account sign-in is coming soon! Your data is currently synced anonymously.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('OK'),
),
],
),
);
},- Archive View (line 93):
onTap: () => context.push('/archive'),- Export Data (line 101):
onTap: () => _showExportDialog(context, ref),Add this method to the class:
void _showExportDialog(BuildContext context, WidgetRef ref) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Export Data'),
content: const Text('Export all your articles and digests as a JSON file?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () async {
Navigator.pop(context);
await _performExport(context, ref);
},
child: const Text('Export'),
),
],
),
);
}
Future<void> _performExport(BuildContext context, WidgetRef ref) async {
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const Center(child: CircularProgressIndicator()),
);
try {
final service = ref.read(supabaseServiceProvider);
final articles = await service.getAllArticlesForExport();
final digests = await service.getAllDigestsForExport();
final exportData = {
'exportedAt': DateTime.now().toIso8601String(),
'articles': articles.map((a) => a.toJson()).toList(),
'digests': digests.map((d) => d.toJson()).toList(),
};
final jsonString = const JsonEncoder.withIndent(' ').convert(exportData);
// Save to temp file and share
final tempDir = await getTemporaryDirectory();
final file = File('${tempDir.path}/readwise_export_${DateTime.now().millisecondsSinceEpoch}.json');
await file.writeAsString(jsonString);
Navigator.pop(context);
await Share.shareXFiles([XFile(file.path)], subject: 'Readwise Data Export');
} catch (e) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Export failed: $e')),
);
}
}- Privacy Policy (line 125):
onTap: () async {
final url = Uri.parse(Env.privacyPolicyUrl);
if (await canLaunchUrl(url)) {
await launchUrl(url, mode: LaunchMode.externalApplication);
}
},- Terms of Service (line 132):
onTap: () async {
final url = Uri.parse(Env.termsOfServiceUrl);
if (await canLaunchUrl(url)) {
await launchUrl(url, mode: LaunchMode.externalApplication);
}
},- Clear All Data (line 183):
onPressed: () async {
Navigator.pop(context);
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const Center(child: CircularProgressIndicator()),
);
try {
await ref.read(supabaseServiceProvider).clearAllData();
Navigator.pop(context);
ref.invalidate(articlesStreamProvider);
ref.invalidate(digestsStreamProvider);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('All data cleared')),
);
} catch (e) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to clear data: $e')),
);
}
},File: pubspec.yaml
Add under dependencies:
share_plus: ^7.2.1Then run flutter pub get.
| File | Action |
|---|---|
lib/core/models/models.dart |
Add UserSettings class |
lib/core/services/supabase_service.dart |
Add 8 new methods |
lib/core/config/env.dart |
Add URL constants |
lib/core/router/app_router.dart |
Add /archive route |
lib/features/settings/screens/settings_screen.dart |
Implement all TODOs |
lib/features/settings/providers/settings_providers.dart |
CREATE NEW |
lib/features/settings/screens/archive_screen.dart |
CREATE NEW |
pubspec.yaml |
Add share_plus if not present |
- Open Settings screen - settings load without error
- Tap Digest Time - time picker appears, selection saves
- Toggle Push Notifications - switch works, persists on restart
- Toggle Analyze Images - switch works, persists on restart
- Toggle Include Comments - switch works, persists on restart
- Tap Sign In - "Coming Soon" dialog appears
- Tap Archived Articles - archive screen opens
- Archive an article, verify it appears in archive
- Restore archived article, verify it returns to library
- Tap Export Data - JSON file generated and share sheet opens
- Tap Privacy Policy - opens URL in browser
- Tap Terms of Service - opens URL in browser
- Tap Clear All Data - confirmation dialog appears
- Confirm Clear All Data - articles and digests are deleted