Files
feeddeck/app/lib/repositories/app_repository.dart
Rico Berger 90fc7532ba [core] Add Client Side Scraping of Sources (#118)
It is now possible to add and update sources via client side scraping.
For that a new edge function `add-or-update-source-v1` was added and the
old `add-source-v1` function was deprecated.

The new function accepts a new `feedData` field, which can contain the
feed for a source. If the field is provided we will not try to get the
feed for a source within our edge function and instead use the provided
data.

Currently this function is only used to add a Reddit source. Later we
plan to extend it for other sources and want to use it to update source
via the app, when the source provider makes heavy use of rate limiting.
2024-01-30 21:59:42 +01:00

427 lines
15 KiB
Dart

import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:feeddeck/models/column.dart';
import 'package:feeddeck/models/deck.dart';
import 'package:feeddeck/models/source.dart';
import 'package:feeddeck/utils/api_exception.dart';
enum FDAppStatus {
uninitialized,
authenticated,
unauthenticated,
}
/// [AppRepository] is the repository for our app. The repository is responsible
/// for managing the state of our app, this includes the authentication status
/// of the current user and if a user is authenticated all his decks, the active
/// deck including all columns and sources for this deck.
///
/// This means that the [_columns] field must always contain the columns for the
/// [_activeDeckId].
class AppRepository with ChangeNotifier {
FDAppStatus _status = FDAppStatus.uninitialized;
String? _activeDeckId;
List<FDDeck> _decks = [];
List<FDColumn> _columns = [];
FDAppStatus get status => _status;
String? get activeDeckId => _activeDeckId;
List<FDDeck> get decks => _decks;
List<FDColumn> get columns => _columns;
/// The [init] function is called to initialize the repository. Before this
/// function is called, the [status] is [FDAppStatus.uninitialized]. Once the
/// function is called, the [status] is set to [FDAppStatus.authenticated] if
/// the user is authenticated or to [FDAppStatus.unauthenticated] if the user
/// is not authenticated.
///
/// To check if the user is authenticated, we check if the current session is
/// not null. If this is the case we check if the user has a active deck and
/// we are loading all the users decks. If the user has a active deck and the
/// deck is in the list of decks we set the active deck to the active deck
/// saved in the storage. If the user has no active deck or the active deck is
/// not in the list of decks we set the active deck to the first deck in the
/// list of decks.
Future<void> init() async {
try {
final session = Supabase.instance.client.auth.currentSession;
if (session != null) {
final SharedPreferences prefs = await SharedPreferences.getInstance();
final String? activeDeckId = prefs.getString('activeDeckId');
_decks = await getDecks();
if (activeDeckId != null &&
_decks.where((deck) => deck.id == activeDeckId).isNotEmpty) {
_activeDeckId = activeDeckId;
await selectDeck(activeDeckId);
} else if (_decks.isNotEmpty) {
await selectDeck(_decks.first.id);
}
_status = FDAppStatus.authenticated;
notifyListeners();
} else {
_status = FDAppStatus.unauthenticated;
notifyListeners();
}
} catch (_) {
_status = FDAppStatus.unauthenticated;
notifyListeners();
}
}
/// [signInWithPassword] is called to sign in a user. The function takes an
/// [email] and a [password] as parameters. The function calls the Supabase
/// client to sign in the user with the given email and password.
///
/// If the user was signed in successfully, we run the same logic as in the
/// [init] function, to set the active deck for the user.
Future<void> signInWithPassword(
String email,
String password,
) async {
await Supabase.instance.client.auth.signInWithPassword(
email: email,
password: password,
);
final SharedPreferences prefs = await SharedPreferences.getInstance();
final String? activeDeckId = prefs.getString('activeDeckId');
_decks = await getDecks();
if (activeDeckId != null &&
_decks.where((deck) => deck.id == activeDeckId).isNotEmpty) {
_activeDeckId = activeDeckId;
await selectDeck(activeDeckId);
} else if (_decks.isNotEmpty) {
await selectDeck(_decks.first.id);
}
_status = FDAppStatus.authenticated;
notifyListeners();
}
/// [signInWithCallback] is called to sign in a user. The function takes an
/// [uri] which should contain the session information as a parameter. The
/// function calls the Supabase client to sign in the user with the given
/// session information.
///
/// If the user was signed in successfully, we run the same logic as in the
/// [init] function, to set the active deck for the user.
Future<void> signInWithCallback(
Uri uri,
) async {
await Supabase.instance.client.auth.getSessionFromUrl(
uri,
storeSession: true,
);
final SharedPreferences prefs = await SharedPreferences.getInstance();
final String? activeDeckId = prefs.getString('activeDeckId');
_decks = await getDecks();
if (activeDeckId != null &&
_decks.where((deck) => deck.id == activeDeckId).isNotEmpty) {
_activeDeckId = activeDeckId;
await selectDeck(activeDeckId);
} else if (_decks.isNotEmpty) {
await selectDeck(_decks.first.id);
}
_status = FDAppStatus.authenticated;
notifyListeners();
}
/// [createDeck] is called to create a new deck for the user. The function
/// takes a [name] as parameter. The function calls the Supabase client to
/// create a new deck for the user with the given name. After the deck was
/// created, the deck is set as the users active deck and the deck is added to
/// the list of decks.
Future<void> createDeck(
String name,
) async {
final data = await Supabase.instance.client
.from('decks')
.insert({
'name': name,
'userId': Supabase.instance.client.auth.currentUser!.id,
})
.select()
.single();
final newDeck = FDDeck.fromJson(data);
final SharedPreferences prefs = await SharedPreferences.getInstance();
await prefs.setString('activeDeckId', newDeck.id);
_decks.add(newDeck);
_activeDeckId = newDeck.id;
notifyListeners();
}
/// [getDecks] is called to get all decks for the user. The function calls the
/// Supabase client to get all decks for the user. The function returns a
/// list of [FDDeck]s.
Future<List<FDDeck>> getDecks() async {
final data = await Supabase.instance.client
.from('decks')
.select('id, name')
.order('name', ascending: true);
return List<FDDeck>.from(data.map((deck) => FDDeck.fromJson(deck)));
}
/// [updateDeck] is called to update a deck for the user. The function takes
/// a [deckId] and a [name] as parameters. The function calls the Supabase
/// client to update the name of the deck. After the deck was updated, the
/// deck is also updated in the list of decks.
Future<void> updateDeck(
String deckId,
String name,
) async {
await Supabase.instance.client
.from('decks')
.update({'name': name}).eq('id', deckId);
for (var i = 0; i < _decks.length; i++) {
if (_decks[i].id == deckId) {
_decks[i].name = name;
break;
}
}
notifyListeners();
}
/// [deleteDeck] is called to delete a deck for the user. The function takes
/// a [deckId] as parameter. The function calls the Supabase client to delete
/// the deck. After the deck was deleted, the deck is also removed from the
/// list of decks. If the deleted deck was the active deck, the active deck is
/// set to `null`.
Future<void> deleteDeck(
String deckId,
) async {
await Supabase.instance.client.from('decks').delete().eq('id', deckId);
_decks.removeWhere((deck) => deck.id == deckId);
if (deckId == _activeDeckId) {
_activeDeckId = null;
}
notifyListeners();
}
/// [selectDeck] is called to set the provided [deckId] as the active deck for
/// the user. The function calls the [getColumns] and [getSources] functions
/// to get the columns for the deck and all sources for each column. After the
/// columns and sources are fetched, the active deck is set to the provided
/// deckId and the columns and sources are stored in the repository.
Future<void> selectDeck(
String deckId,
) async {
final columns = await getColumns(deckId);
for (final column in columns) {
column.sources = await getSources(column.id);
}
final SharedPreferences prefs = await SharedPreferences.getInstance();
await prefs.setString('activeDeckId', deckId);
_columns = columns;
_activeDeckId = deckId;
notifyListeners();
}
/// [createColumn] is called to create a new column for the active deck. The
/// function takes a [name] as parameter. The function calls the Supabase
/// client to create a new column for the active deck with the given name.
/// Finally the newly created column is added to the list of columns.
Future<void> createColumn(
String name,
) async {
final data = await Supabase.instance.client.from('columns').insert({
'deckId': _activeDeckId,
'userId': Supabase.instance.client.auth.currentUser!.id,
'name': name,
'position': _columns.length,
}).select();
final newColumn =
List<FDColumn>.from(data.map((column) => FDColumn.fromJson(column)));
_columns.addAll(newColumn);
notifyListeners();
}
/// [getColumns] is called to get all columns for the deck with the provided
/// [deckId]. The function calls the Supabase client to get all columns for
/// the deck. The function returns a list of [FDColumn]s.
Future<List<FDColumn>> getColumns(
String deckId,
) async {
final data = await Supabase.instance.client
.from('columns')
.select('id, name, position')
.eq('deckId', deckId)
.order('position', ascending: true);
return List<FDColumn>.from(data.map((column) => FDColumn.fromJson(column)));
}
/// [deleteColumn] is called to delete a column with the provided [columnId].
/// The function calls the Supabase client to delete the column. After the
/// column was deleted, the column is also removed from the list of columns.
Future<void> deleteColumn(
String columnId,
) async {
await Supabase.instance.client.from('columns').delete().eq('id', columnId);
_columns.removeWhere((column) => column.id == columnId);
notifyListeners();
}
/// [updateColumn] is called to update a column with the provided [columnId].
/// The function takes a [name] as parameter. The function calls the Supabase
/// client to update the name of the column. After the column was updated, the
/// column is also updated in the list of columns.
Future<void> updateColumn(
String columnId,
String name,
) async {
await Supabase.instance.client
.from('columns')
.update({'name': name}).eq('id', columnId);
for (var i = 0; i < _columns.length; i++) {
if (_columns[i].id == columnId) {
_columns[i].name = name;
break;
}
}
notifyListeners();
}
/// [updateColumnPositions] is called to update the positions of two columns
/// with the provided [index1] and [index2]. The function calls the Supabase
/// client to update the positions of the columns. After the columns were
/// updated, the columns are also updated in the list of columns.
Future<void> updateColumnPositions(
int index1,
int index2,
) async {
await Supabase.instance.client
.from('columns')
.update({'position': _columns[index2].position}).eq(
'id',
_columns[index1].id,
);
await Supabase.instance.client
.from('columns')
.update({'position': _columns[index1].position}).eq(
'id',
_columns[index2].id,
);
final tmp = _columns[index1];
_columns[index1] = _columns[index2];
_columns[index2] = tmp;
_columns[index1].position = index1;
_columns[index2].position = index2;
notifyListeners();
}
/// [getSources] is called to get all sources for the column with the provided
/// [columnId]. The function calls the Supabase client to get all sources for
/// the column. The function returns a list of [FDSource]s.
Future<List<FDSource>> getSources(
String columnId,
) async {
final data = await Supabase.instance.client
.from('sources')
.select('id, type, title, options, link, icon')
.eq('columnId', columnId)
.order('createdAt', ascending: true);
return List<FDSource>.from(data.map((source) => FDSource.fromJson(source)));
}
/// [deleteSource] is called to delete a source with the provided [sourceId].
/// The function calls the Supabase client to delete the source. After the
/// source was deleted, the source is also removed from the list of sources of
/// the column with the provided [columnId].
Future<void> deleteSource(
String columnId,
String sourceId,
) async {
await Supabase.instance.client.from('sources').delete().eq('id', sourceId);
/// It could take some time before we can retrieve the items after a source
/// was deleted, so that we delay the repository update by 3 seconds.
await Future.delayed(const Duration(seconds: 3));
for (var i = 0; i < _columns.length; i++) {
if (_columns[i].id == columnId) {
_columns[i].sources.removeWhere((source) => source.id == sourceId);
break;
}
}
notifyListeners();
}
/// [addSource] is called to add a source to the column with the provided
/// [columnId]. Next to [columnId] a user must also provide the [type] and
/// [options] for the source. The function calls the `add-or-update-source-v1`
/// edge function via the Supabase client to create the source. When the
/// source was created the newly returned source is added to the list of
/// sources of the column with the provided [columnId].
///
/// The optional [feedData] parameter is used to provide the feed data for the
/// source. This is can be used to scrape the source data via the client (app)
/// instead of the server (scheduler / worker).
Future<void> addSource(
String columnId,
FDSourceType type,
FDSourceOptions options, [
String? feedData,
]) async {
final result = await Supabase.instance.client.functions.invoke(
'add-or-update-source-v1',
body: {
'source': {
'id': '',
'columnId': columnId,
'userId': '',
'type': type.toShortString(),
'title': '',
'options': options.toJson(),
},
'feedData': feedData,
},
);
if (result.status != 200) {
throw ApiException(result.data['error'], result.status);
}
/// It could take some time before we can retrieve the items for a newly
/// added source, so that we delay the repository update by 3 seconds.
await Future.delayed(const Duration(seconds: 3));
for (var i = 0; i < _columns.length; i++) {
if (_columns[i].id == columnId) {
_columns[i].sources.add(FDSource.fromJson(result.data));
break;
}
}
notifyListeners();
}
}