Test how feasible it is to load exercises via powersync as well

This commit is contained in:
Roland Geider
2025-10-24 01:21:13 +02:00
parent 944830eb0f
commit 7969376dce
16 changed files with 5194 additions and 73 deletions

View File

@@ -2,15 +2,40 @@ import 'package:drift/drift.dart';
import 'package:drift_sqlite_async/drift_sqlite_async.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:powersync/powersync.dart' show uuid;
import 'package:wger/database/weight/database.dart';
import 'package:wger/models/body_weight/weight_entry.dart';
import 'package:wger/models/exercises/category.dart';
import 'package:wger/models/exercises/equipment.dart';
import 'package:wger/models/exercises/exercise.dart';
import 'package:wger/models/exercises/image.dart';
import 'package:wger/models/exercises/language.dart';
import 'package:wger/models/exercises/muscle.dart';
import 'package:wger/models/exercises/translation.dart';
import 'package:wger/models/exercises/video.dart';
import 'powersync.dart';
import 'tables/exercise.dart';
import 'tables/language.dart';
import 'tables/weight.dart';
part 'database.g.dart';
@DriftDatabase(
tables: [
// Core
LanguageTable,
// Exercises
ExerciseTable,
ExerciseTranslationTable,
MuscleTable,
ExerciseMuscleM2N,
ExerciseSecondaryMuscleM2N,
EquipmentTable,
ExerciseCategoryTable,
ExerciseImageTable,
ExerciseVideoTable,
// User data
WeightEntryTable,
],
//include: {'queries.drift'},

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,114 @@
import 'package:drift/drift.dart';
import 'package:wger/models/exercises/category.dart';
import 'package:wger/models/exercises/equipment.dart';
import 'package:wger/models/exercises/exercise.dart';
import 'package:wger/models/exercises/image.dart';
import 'package:wger/models/exercises/muscle.dart';
import 'package:wger/models/exercises/translation.dart';
import 'package:wger/models/exercises/video.dart';
@UseRowClass(Exercise)
class ExerciseTable extends Table {
@override
String get tableName => 'exercises_exercise';
IntColumn get id => integer()();
TextColumn get uuid => text()();
IntColumn get variationId => integer().nullable().named('variation_id')();
IntColumn get categoryId => integer().named('category_id')();
DateTimeColumn get created => dateTime()();
DateTimeColumn get lastUpdate => dateTime().named('last_update')();
}
@UseRowClass(Translation)
class ExerciseTranslationTable extends Table {
@override
String get tableName => 'exercises_translation';
IntColumn get id => integer()();
TextColumn get uuid => text()();
IntColumn get exerciseId => integer().named('exercise_id')();
IntColumn get languageId => integer().named('language_id')();
TextColumn get name => text()();
TextColumn get description => text()();
DateTimeColumn get created => dateTime()();
DateTimeColumn get lastUpdate => dateTime().named('last_update')();
}
@UseRowClass(ExerciseCategory)
class ExerciseCategoryTable extends Table {
@override
String get tableName => 'exercises_exercisecategory';
IntColumn get id => integer()();
TextColumn get name => text()();
}
@UseRowClass(Equipment)
class EquipmentTable extends Table {
@override
String get tableName => 'exercises_equipment';
IntColumn get id => integer()();
TextColumn get name => text()();
}
@UseRowClass(Muscle)
class MuscleTable extends Table {
@override
String get tableName => 'exercises_muscle';
IntColumn get id => integer()();
TextColumn get name => text()();
TextColumn get nameEn => text().named('name_en')();
BoolColumn get isFront => boolean().named('is_front')();
}
class ExerciseMuscleM2N extends Table {
@override
String get tableName => 'exercises_exercise_muscles';
IntColumn get id => integer()();
IntColumn get exerciseId => integer().named('exercise_id')();
IntColumn get muscleId => integer().named('muscle_id')();
}
class ExerciseSecondaryMuscleM2N extends Table {
@override
String get tableName => 'exercises_exercise_muscles_secondary';
IntColumn get id => integer()();
IntColumn get exerciseId => integer().named('exercise_id')();
IntColumn get muscleId => integer().named('muscle_id')();
}
@UseRowClass(ExerciseImage)
class ExerciseImageTable extends Table {
@override
String get tableName => 'exercises_exerciseimage';
IntColumn get id => integer()();
TextColumn get uuid => text()();
IntColumn get exerciseId => integer().named('exercise_id')();
TextColumn get url => text()();
BoolColumn get isMain => boolean().named('is_main')();
}
@UseRowClass(Video)
class ExerciseVideoTable extends Table {
@override
String get tableName => 'exercises_exercisevideo';
IntColumn get id => integer()();
TextColumn get uuid => text()();
IntColumn get exerciseId => integer().named('exercise_id')();
TextColumn get url => text()();
IntColumn get size => integer()();
IntColumn get duration => integer()();
IntColumn get width => integer()();
IntColumn get height => integer()();
TextColumn get codec => text()();
TextColumn get codecLong => text().named('codec_long')();
IntColumn get licenseId => integer().named('license_id')();
TextColumn get licenseAuthor => text().named('license_author')();
}

View File

@@ -0,0 +1,12 @@
import 'package:drift/drift.dart';
import 'package:wger/models/exercises/language.dart';
@UseRowClass(Language)
class LanguageTable extends Table {
@override
String get tableName => 'core_language';
IntColumn get id => integer()();
TextColumn get shortName => text().named('short_name')();
TextColumn get fullName => text().named('full_name')();
}

View File

@@ -38,10 +38,10 @@ class Exercise extends Equatable {
final _logger = Logger('ExerciseModel');
@JsonKey(required: true)
late final int? id;
late final int id;
@JsonKey(required: true)
late final String? uuid;
late final String uuid;
@JsonKey(required: true, name: 'variations')
late final int? variationId;
@@ -95,12 +95,13 @@ class Exercise extends Equatable {
List<String> authorsGlobal = [];
Exercise({
this.id,
this.uuid,
required this.id,
required this.uuid,
this.created,
this.lastUpdate,
this.lastUpdateGlobal,
// this.lastUpdateGlobal,
this.variationId,
required this.categoryId,
List<Muscle>? muscles,
List<Muscle>? musclesSecondary,
List<Equipment>? equipment,
@@ -142,6 +143,8 @@ class Exercise extends Equatable {
}
this.authors = authors ?? [];
this.authorsGlobal = authorsGlobal ?? [];
lastUpdateGlobal = DateTime.now();
}
bool get showPlateCalculator => equipment.map((e) => e.id).contains(ID_EQUIPMENT_BARBELL);
@@ -160,7 +163,7 @@ class Exercise extends Equatable {
created = exerciseData.created;
lastUpdate = exerciseData.lastUpdate;
lastUpdateGlobal = exerciseData.lastUpdateGlobal;
// lastUpdateGlobal = exerciseData.lastUpdateGlobal;
musclesSecondary = exerciseData.muscles;
muscles = exerciseData.muscles;

View File

@@ -23,16 +23,14 @@ Exercise _$ExerciseFromJson(Map<String, dynamic> json) {
],
);
return Exercise(
id: (json['id'] as num?)?.toInt(),
uuid: json['uuid'] as String?,
id: (json['id'] as num).toInt(),
uuid: json['uuid'] as String,
created: json['created'] == null ? null : DateTime.parse(json['created'] as String),
lastUpdate: json['last_update'] == null
? null
: DateTime.parse(json['last_update'] as String),
lastUpdateGlobal: json['last_update_global'] == null
? null
: DateTime.parse(json['last_update_global'] as String),
variationId: (json['variations'] as num?)?.toInt(),
categoryId: (json['category'] as num).toInt(),
translations: (json['translations'] as List<dynamic>?)
?.map((e) => Translation.fromJson(e as Map<String, dynamic>))
.toList(),
@@ -42,7 +40,9 @@ Exercise _$ExerciseFromJson(Map<String, dynamic> json) {
json['categories'] as Map<String, dynamic>,
),
)
..categoryId = (json['category'] as num).toInt()
..lastUpdateGlobal = json['last_update_global'] == null
? null
: DateTime.parse(json['last_update_global'] as String)
..musclesIds = (json['muscles'] as List<dynamic>).map((e) => (e as num).toInt()).toList()
..musclesSecondaryIds = (json['muscles_secondary'] as List<dynamic>)
.map((e) => (e as num).toInt())

View File

@@ -55,8 +55,8 @@ class Video {
@JsonKey(name: 'codec_long', required: true)
final String codecLong;
@JsonKey(required: true)
final int license;
@JsonKey(required: true, name: 'license')
final int licenseId;
@JsonKey(name: 'license_author', required: true)
final String? licenseAuthor;
@@ -72,7 +72,7 @@ class Video {
required this.height,
required this.codec,
required this.codecLong,
required this.license,
required this.licenseId,
required this.licenseAuthor,
});

View File

@@ -35,7 +35,7 @@ Video _$VideoFromJson(Map<String, dynamic> json) {
height: (json['height'] as num).toInt(),
codec: json['codec'] as String,
codecLong: json['codec_long'] as String,
license: (json['license'] as num).toInt(),
licenseId: (json['license'] as num).toInt(),
licenseAuthor: json['license_author'] as String?,
);
}
@@ -51,6 +51,6 @@ Map<String, dynamic> _$VideoToJson(Video instance) => <String, dynamic>{
'height': instance.height,
'codec': instance.codec,
'codec_long': instance.codecLong,
'license': instance.license,
'license': instance.licenseId,
'license_author': instance.licenseAuthor,
};

View File

@@ -1,46 +0,0 @@
// This file performs setup of the PowerSync database
import 'package:logging/logging.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:powersync/powersync.dart';
import 'package:wger/powersync/connector.dart';
import 'package:wger/powersync/schema.dart';
final logger = Logger('powersync-django');
/// Global reference to the database
late final PowerSyncDatabase db;
// Hacky flag to ensure the database is only initialized once, better to do this with listeners
bool _dbInitialized = false;
Future<String> getDatabasePath() async {
final dir = await getApplicationSupportDirectory();
return join(dir.path, 'powersync-demo.db');
}
// opens the database and connects if logged in
Future<void> openDatabase(bool connect, String baseUrl, String powersyncUrl) async {
// Open the local database
if (!_dbInitialized) {
db = PowerSyncDatabase(schema: schema, path: await getDatabasePath(), logger: attachedLogger);
await db.initialize();
_dbInitialized = true;
}
if (connect) {
// If the user is already logged in, connect immediately.
// Otherwise, connect once logged in.
final currentConnector = DjangoConnector(baseUrl: baseUrl, powersyncUrl: powersyncUrl);
db.connect(connector: currentConnector);
// TODO: should we respond to login state changing? like here:
// https://www.powersync.com/blog/flutter-tutorial-building-an-offline-first-chat-app-with-supabase-and-powersync#implement-auth-methods
}
}
/// Explicit sign out - clear database and log out.
Future<void> logout() async {
await db.disconnectAndClear();
}

View File

@@ -32,7 +32,7 @@ class ApiClient {
final prefs = PreferenceHelper.asyncPref;
final apiData = json.decode((await prefs.getString(PREFS_USER))!);
_logger.info('posting our token "${apiData["token"]}" to $baseUrl/api/v2/powersync-token');
// _logger.info('posting our token "${apiData["token"]}" to $baseUrl/api/v2/powersync-token');
token = apiData['token'];
final response = await http.get(
Uri.parse('$baseUrl/api/v2/powersync-token'),
@@ -41,9 +41,9 @@ class ApiClient {
HttpHeaders.authorizationHeader: 'Token ${apiData["token"]}',
},
);
_logger.info('response: status ${response.statusCode}, body ${response.body}');
// _logger.info('response: status ${response.statusCode}, body ${response.body}');
if (response.statusCode == 200) {
_logger.log(Level.ALL, response.body);
// _logger.fine(response.body);
final result = json.decode(response.body);
return result;

View File

@@ -1,11 +1,52 @@
import 'package:powersync/powersync.dart';
const tableMuscle = 'exercises_muscle';
const tableBodyWeight = 'weight_weightentry';
Schema schema = const Schema([
//
// Core
//
Table(
tableMuscle,
'core_language',
[
Column.text('short_name'),
Column.text('full_name'),
],
),
//
// Exercises
//
Table(
'exercises_exercise',
[
Column.text('uuid'),
Column.integer('category_id'),
Column.integer('variation_id'),
Column.text('created'),
Column.text('last_update'),
],
indexes: [
Index('category', [IndexedColumn('category_id')]),
Index('variation', [IndexedColumn('variation_id')]),
],
),
Table(
'exercises_translation',
[
Column.text('uuid'),
Column.integer('language_id'),
Column.integer('exercise_id'),
Column.text('description'),
Column.text('name'),
Column.text('created'),
Column.text('last_update'),
],
indexes: [
Index('language', [IndexedColumn('language_id')]),
Index('exercise', [IndexedColumn('exercise_id')]),
],
),
Table(
'exercises_muscle',
[
Column.text('name'),
Column.text('name_en'),
@@ -13,7 +54,70 @@ Schema schema = const Schema([
],
),
Table(
tableBodyWeight,
'exercises_exercise_muscles',
[
Column.integer('exercise_id'),
Column.integer('muscle_id'),
],
indexes: [
Index('muscle', [IndexedColumn('muscle_id')]),
Index('exercise', [IndexedColumn('exercise_id')]),
],
),
Table(
'exercises_exercise_muscles_secondary',
[
Column.integer('exercise_id'),
Column.integer('muscle_id'),
],
indexes: [
Index('muscle', [IndexedColumn('muscle_id')]),
Index('exercise', [IndexedColumn('exercise_id')]),
],
),
Table(
'exercises_equipment',
[
Column.text('name'),
],
),
Table(
'exercises_exercisecategory',
[
Column.text('name'),
],
),
Table(
'exercises_exerciseimage',
[
Column.text('uuid'),
Column.integer('exercise_id'),
Column.text('url'),
Column.integer('is_main'),
],
),
Table(
'exercises_exercisevideo',
[
Column.text('uuid'),
Column.integer('exercise_id'),
Column.text('url'),
Column.integer('size'),
Column.integer('duration'),
Column.integer('width'),
Column.integer('height'),
Column.text('codec'),
Column.text('codec_long'),
Column.integer('license_id'),
Column.text('license_author'),
],
),
//
// User data
//
Table(
'weight_weightentry',
[
Column.text('uuid'),
Column.real('weight'),

View File

@@ -0,0 +1,14 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:wger/database/powersync/database.dart';
import 'package:wger/models/exercises/language.dart';
part 'core_data.g.dart';
@riverpod
final class LanguageNotifier extends _$LanguageNotifier {
@override
Stream<List<Language>> build() {
final db = ref.read(driftPowerSyncDatabase);
return db.select(db.languageTable).watch();
}
}

View File

@@ -0,0 +1,55 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'core_data.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(LanguageNotifier)
const languageProvider = LanguageNotifierProvider._();
final class LanguageNotifierProvider
extends $StreamNotifierProvider<LanguageNotifier, List<Language>> {
const LanguageNotifierProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'languageProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$languageNotifierHash();
@$internal
@override
LanguageNotifier create() => LanguageNotifier();
}
String _$languageNotifierHash() => r'7945fdd6a4a80c381615b153209e4b70d9c81332';
abstract class _$LanguageNotifier extends $StreamNotifier<List<Language>> {
Stream<List<Language>> build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<AsyncValue<List<Language>>, List<Language>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<List<Language>>, List<Language>>,
AsyncValue<List<Language>>,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}

View File

@@ -0,0 +1,116 @@
import 'package:drift/drift.dart';
import 'package:logging/logging.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:wger/database/powersync/database.dart';
import 'package:wger/models/exercises/equipment.dart';
import 'package:wger/models/exercises/exercise.dart';
part 'exercise_data.g.dart';
@riverpod
final class ExerciseNotifier extends _$ExerciseNotifier {
final _logger = Logger('ExerciseNotifier');
@override
Stream<List<Exercise>> build() {
final db = ref.read(driftPowerSyncDatabase);
final primaryMuscleTable = db.alias(db.muscleTable, 'pm');
final secondaryMuscleTable = db.alias(db.muscleTable, 'sm');
final joined = db.select(db.exerciseTable).join([
// Translations
leftOuterJoin(
db.exerciseTranslationTable,
db.exerciseTranslationTable.exerciseId.equalsExp(db.exerciseTable.id),
),
// Exercise <-> Muscle
leftOuterJoin(
db.exerciseMuscleM2N,
db.exerciseMuscleM2N.exerciseId.equalsExp(db.exerciseTable.id),
),
leftOuterJoin(
primaryMuscleTable,
primaryMuscleTable.id.equalsExp(db.exerciseMuscleM2N.muscleId),
),
// Exercise <-> Secondary Muscle
leftOuterJoin(
db.exerciseSecondaryMuscleM2N,
db.exerciseSecondaryMuscleM2N.exerciseId.equalsExp(db.exerciseTable.id),
),
leftOuterJoin(
secondaryMuscleTable,
secondaryMuscleTable.id.equalsExp(db.exerciseSecondaryMuscleM2N.muscleId),
),
// Category
leftOuterJoin(
db.exerciseCategoryTable,
db.exerciseCategoryTable.id.equalsExp(db.exerciseTable.categoryId),
),
// Images
leftOuterJoin(
db.exerciseImageTable,
db.exerciseImageTable.exerciseId.equalsExp(db.exerciseTable.id),
),
]);
return joined.watch().map((rows) {
final Map<int, Exercise> map = {};
for (final row in rows) {
final exercise = row.readTable(db.exerciseTable);
final primaryMuscle = row.readTableOrNull(primaryMuscleTable);
final secondaryMuscle = row.readTableOrNull(secondaryMuscleTable);
final image = row.readTableOrNull(db.exerciseImageTable);
final video = row.readTableOrNull(db.exerciseVideoTable);
final translation = row.readTableOrNull(db.exerciseTranslationTable);
final category = row.readTableOrNull(db.exerciseCategoryTable);
final entry = map.putIfAbsent(
exercise.id,
() => exercise,
);
if (category != null) {
entry.category = category;
}
if (translation != null && !entry.translations.any((t) => t.id == translation.id)) {
entry.translations.add(translation);
}
if (image != null && !entry.images.any((t) => t.id == image.id)) {
entry.images.add(image);
}
if (video != null && !entry.videos.any((t) => t.id == video.id)) {
entry.videos.add(video);
}
if (primaryMuscle != null && !entry.muscles.any((m) => m.id == primaryMuscle.id)) {
entry.muscles.add(primaryMuscle);
}
if (secondaryMuscle != null &&
!entry.musclesSecondary.any((m) => m.id == secondaryMuscle.id)) {
entry.musclesSecondary.add(secondaryMuscle);
}
}
return map.values.toList();
});
}
}
@riverpod
final class ExerciseEquipmentNotifier extends _$ExerciseEquipmentNotifier {
@override
Stream<List<Equipment>> build() {
final db = ref.read(driftPowerSyncDatabase);
return db.select(db.equipmentTable).watch();
}
}

View File

@@ -0,0 +1,100 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'exercise_data.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(ExerciseNotifier)
const exerciseProvider = ExerciseNotifierProvider._();
final class ExerciseNotifierProvider
extends $StreamNotifierProvider<ExerciseNotifier, List<Exercise>> {
const ExerciseNotifierProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'exerciseProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$exerciseNotifierHash();
@$internal
@override
ExerciseNotifier create() => ExerciseNotifier();
}
String _$exerciseNotifierHash() => r'4f6613f81e292dd3c74f9eebbbb20482b8da1e42';
abstract class _$ExerciseNotifier extends $StreamNotifier<List<Exercise>> {
Stream<List<Exercise>> build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<AsyncValue<List<Exercise>>, List<Exercise>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<List<Exercise>>, List<Exercise>>,
AsyncValue<List<Exercise>>,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}
@ProviderFor(ExerciseEquipmentNotifier)
const exerciseEquipmentProvider = ExerciseEquipmentNotifierProvider._();
final class ExerciseEquipmentNotifierProvider
extends $StreamNotifierProvider<ExerciseEquipmentNotifier, List<Equipment>> {
const ExerciseEquipmentNotifierProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'exerciseEquipmentProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$exerciseEquipmentNotifierHash();
@$internal
@override
ExerciseEquipmentNotifier create() => ExerciseEquipmentNotifier();
}
String _$exerciseEquipmentNotifierHash() => r'581494c6fd4f58e210ea1962fa48bda613c5827f';
abstract class _$ExerciseEquipmentNotifier extends $StreamNotifier<List<Equipment>> {
Stream<List<Equipment>> build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<AsyncValue<List<Equipment>>, List<Equipment>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<List<Equipment>>, List<Equipment>>,
AsyncValue<List<Equipment>>,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}