mirror of
https://github.com/wger-project/flutter.git
synced 2026-02-18 00:17:48 +01:00
Start actually interacting with powersync
This commit is contained in:
@@ -17,6 +17,11 @@ PODS:
|
||||
- FlutterMacOS
|
||||
- pointer_interceptor_ios (0.0.1):
|
||||
- Flutter
|
||||
- powersync-sqlite-core (0.4.7)
|
||||
- powersync_flutter_libs (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- powersync-sqlite-core (~> 0.4.6)
|
||||
- rive_common (0.0.1):
|
||||
- Flutter
|
||||
- shared_preferences_foundation (0.0.1):
|
||||
@@ -63,6 +68,7 @@ DEPENDENCIES:
|
||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||
- pointer_interceptor_ios (from `.symlinks/plugins/pointer_interceptor_ios/ios`)
|
||||
- powersync_flutter_libs (from `.symlinks/plugins/powersync_flutter_libs/darwin`)
|
||||
- rive_common (from `.symlinks/plugins/rive_common/ios`)
|
||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||
- sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/darwin`)
|
||||
@@ -71,6 +77,7 @@ DEPENDENCIES:
|
||||
|
||||
SPEC REPOS:
|
||||
trunk:
|
||||
- powersync-sqlite-core
|
||||
- sqlite3
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
@@ -92,6 +99,8 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/path_provider_foundation/darwin"
|
||||
pointer_interceptor_ios:
|
||||
:path: ".symlinks/plugins/pointer_interceptor_ios/ios"
|
||||
powersync_flutter_libs:
|
||||
:path: ".symlinks/plugins/powersync_flutter_libs/darwin"
|
||||
rive_common:
|
||||
:path: ".symlinks/plugins/rive_common/ios"
|
||||
shared_preferences_foundation:
|
||||
@@ -113,6 +122,8 @@ SPEC CHECKSUMS:
|
||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
|
||||
pointer_interceptor_ios: ec847ef8b0915778bed2b2cef636f4d177fa8eed
|
||||
powersync-sqlite-core: 7d97e321b00adbbc4d87cc8bab3a8895c855327c
|
||||
powersync_flutter_libs: 19fc6b96ff8155ffea72a08990f6c9f2e712b8a6
|
||||
rive_common: dd421daaf9ae69f0125aa761dd96abd278399952
|
||||
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
|
||||
sqlite3: 73513155ec6979715d3904ef53a8d68892d4032b
|
||||
|
||||
65
lib/api_client.dart
Normal file
65
lib/api_client.dart
Normal file
@@ -0,0 +1,65 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:wger/helpers/shared_preferences.dart';
|
||||
|
||||
import 'helpers/consts.dart';
|
||||
|
||||
class ApiClient {
|
||||
final _logger = Logger('powersync-ApiClient');
|
||||
|
||||
final String baseUrl;
|
||||
|
||||
ApiClient(this.baseUrl);
|
||||
|
||||
/// Returns a powersync JWT token token
|
||||
///
|
||||
/// Note that at the moment we use the permanent API token for authentication
|
||||
/// but this should be probably changed to the wger API JWT tokens in the
|
||||
/// future since they are not permanent and could be easily revoked.
|
||||
Future<Map<String, dynamic>> getPowersyncToken() async {
|
||||
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');
|
||||
final response = await http.get(
|
||||
Uri.parse('$baseUrl/api/v2/powersync-token'),
|
||||
headers: {
|
||||
HttpHeaders.contentTypeHeader: 'application/json',
|
||||
HttpHeaders.authorizationHeader: 'Token ${apiData["token"]}',
|
||||
},
|
||||
);
|
||||
_logger.info('response: status ${response.statusCode}, body ${response.body}');
|
||||
if (response.statusCode == 200) {
|
||||
_logger.log(Level.ALL, response.body);
|
||||
return json.decode(response.body);
|
||||
}
|
||||
throw Exception('Failed to fetch token');
|
||||
}
|
||||
|
||||
Future<void> upsert(Map<String, dynamic> record) async {
|
||||
await http.put(
|
||||
Uri.parse('$baseUrl/api/upload-powersync-data'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: json.encode(record),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> update(Map<String, dynamic> record) async {
|
||||
await http.patch(
|
||||
Uri.parse('$baseUrl/api/upload-powersync-data'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: json.encode(record),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> delete(Map<String, dynamic> record) async {
|
||||
await http.delete(
|
||||
Uri.parse('$baseUrl/api/v2/upload-powersync-data'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: json.encode(record),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,9 @@ extension MeasurementChartEntryListExtensions on List<MeasurementChartEntry> {
|
||||
// this also helps with computing delta's across the entire window
|
||||
List<MeasurementChartEntry> whereDateWithInterpolation(DateTime start, DateTime? end) {
|
||||
// Make sure our list is sorted by date
|
||||
sort((a, b) => a.date.compareTo(b.date));
|
||||
// Avoid mutating the original list (it may be unmodifiable/const). Work on a copy.
|
||||
final List<MeasurementChartEntry> sorted = [...this];
|
||||
sorted.sort((a, b) => a.date.compareTo(b.date));
|
||||
|
||||
// Initialize result list
|
||||
final List<MeasurementChartEntry> result = [];
|
||||
@@ -46,7 +48,7 @@ extension MeasurementChartEntryListExtensions on List<MeasurementChartEntry> {
|
||||
MeasurementChartEntry? lastBeforeEnd;
|
||||
|
||||
// Single pass through the data
|
||||
for (final entry in this) {
|
||||
for (final entry in sorted) {
|
||||
if (entry.date.isSameDayAs(start)) {
|
||||
hasEntryOnStartDay = true;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
*/
|
||||
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
import 'package:powersync/sqlite3.dart' as sqlite;
|
||||
import 'package:wger/helpers/json.dart';
|
||||
|
||||
part 'weight_entry.g.dart';
|
||||
@@ -24,7 +25,7 @@ part 'weight_entry.g.dart';
|
||||
@JsonSerializable()
|
||||
class WeightEntry {
|
||||
@JsonKey(required: true)
|
||||
int? id;
|
||||
String? id;
|
||||
|
||||
@JsonKey(required: true, fromJson: stringToNum, toJson: numToString)
|
||||
late num weight = 0;
|
||||
@@ -32,7 +33,7 @@ class WeightEntry {
|
||||
@JsonKey(required: true)
|
||||
late DateTime date;
|
||||
|
||||
WeightEntry({this.id, weight, DateTime? date}) {
|
||||
WeightEntry({this.id, num? weight, DateTime? date}) {
|
||||
this.date = date ?? DateTime.now();
|
||||
|
||||
if (weight != null) {
|
||||
@@ -40,7 +41,15 @@ class WeightEntry {
|
||||
}
|
||||
}
|
||||
|
||||
WeightEntry copyWith({int? id, int? weight, DateTime? date}) => WeightEntry(
|
||||
factory WeightEntry.fromRow(sqlite.Row row) {
|
||||
return WeightEntry(
|
||||
id: row['uuid'],
|
||||
date: DateTime.parse(row['date']),
|
||||
weight: row['weight'],
|
||||
);
|
||||
}
|
||||
|
||||
WeightEntry copyWith({String? id, int? weight, DateTime? date}) => WeightEntry(
|
||||
id: id,
|
||||
weight: weight ?? this.weight,
|
||||
date: date ?? this.date,
|
||||
|
||||
@@ -9,7 +9,7 @@ part of 'weight_entry.dart';
|
||||
WeightEntry _$WeightEntryFromJson(Map<String, dynamic> json) {
|
||||
$checkKeys(json, requiredKeys: const ['id', 'weight', 'date']);
|
||||
return WeightEntry(
|
||||
id: (json['id'] as num?)?.toInt(),
|
||||
id: json['id'] as String?,
|
||||
weight: stringToNum(json['weight'] as String?),
|
||||
date: json['date'] == null ? null : DateTime.parse(json['date'] as String),
|
||||
);
|
||||
|
||||
22
lib/models/powersync/schema.dart
Normal file
22
lib/models/powersync/schema.dart
Normal file
@@ -0,0 +1,22 @@
|
||||
import 'package:powersync/powersync.dart';
|
||||
|
||||
const tableMuscles = 'exercises_muscle';
|
||||
const tableBodyWeights = 'weight_weightentry';
|
||||
|
||||
Schema schema = const Schema([
|
||||
Table(
|
||||
tableMuscles,
|
||||
[
|
||||
Column.text('name'),
|
||||
Column.text('name_en'),
|
||||
Column.text('is_front'),
|
||||
],
|
||||
),
|
||||
Table(
|
||||
tableBodyWeights,
|
||||
[
|
||||
Column.real('weight'),
|
||||
Column.text('date'),
|
||||
],
|
||||
),
|
||||
]);
|
||||
122
lib/powersync.dart
Normal file
122
lib/powersync.dart
Normal file
@@ -0,0 +1,122 @@
|
||||
// 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/api_client.dart';
|
||||
import 'package:wger/models/powersync/schema.dart';
|
||||
|
||||
final logger = Logger('powersync-django');
|
||||
|
||||
/// Postgres Response codes that we cannot recover from by retrying.
|
||||
final List<RegExp> fatalResponseCodes = [
|
||||
// Class 22 — Data Exception
|
||||
// Examples include data type mismatch.
|
||||
RegExp(r'^22...$'),
|
||||
// Class 23 — Integrity Constraint Violation.
|
||||
// Examples include NOT NULL, FOREIGN KEY and UNIQUE violations.
|
||||
RegExp(r'^23...$'),
|
||||
// INSUFFICIENT PRIVILEGE - typically a row-level security violation
|
||||
RegExp(r'^42501$'),
|
||||
];
|
||||
|
||||
class DjangoConnector extends PowerSyncBackendConnector {
|
||||
PowerSyncDatabase db;
|
||||
String baseUrl;
|
||||
String powersyncUrl;
|
||||
late ApiClient apiClient;
|
||||
|
||||
DjangoConnector(this.db, this.baseUrl, this.powersyncUrl) {
|
||||
apiClient = ApiClient(baseUrl);
|
||||
}
|
||||
|
||||
/// Get a token to authenticate against the PowerSync instance.
|
||||
@override
|
||||
Future<PowerSyncCredentials?> fetchCredentials() async {
|
||||
// Somewhat contrived to illustrate usage, see auth docs here:
|
||||
// https://docs.powersync.com/usage/installation/authentication-setup/custom
|
||||
// final wgerSession = await apiClient.getWgerJWTToken();
|
||||
final session = await apiClient.getPowersyncToken();
|
||||
// note: we don't set userId and expires property here. not sure if needed
|
||||
logger.info('Powersync Url: $powersyncUrl');
|
||||
return PowerSyncCredentials(endpoint: powersyncUrl, token: session['token']);
|
||||
}
|
||||
|
||||
// Upload pending changes to Postgres via Django backend
|
||||
// this is generic. on the django side we inspect the request and do model-specific operations
|
||||
// would it make sense to do api calls here specific to the relevant model? (e.g. put to a todo-specific endpoint)
|
||||
@override
|
||||
Future<void> uploadData(PowerSyncDatabase database) async {
|
||||
final transaction = await database.getNextCrudTransaction();
|
||||
|
||||
if (transaction == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
for (final op in transaction.crud) {
|
||||
final record = {
|
||||
'table': op.table,
|
||||
'data': {'id': op.id, ...?op.opData},
|
||||
};
|
||||
|
||||
logger.fine('DIETER Uploading record', record);
|
||||
|
||||
switch (op.op) {
|
||||
case UpdateType.put:
|
||||
await apiClient.upsert(record);
|
||||
break;
|
||||
case UpdateType.patch:
|
||||
await apiClient.update(record);
|
||||
break;
|
||||
case UpdateType.delete:
|
||||
await apiClient.delete(record);
|
||||
break;
|
||||
}
|
||||
}
|
||||
await transaction.complete();
|
||||
} on Exception catch (e) {
|
||||
logger.severe('Error uploading data', e);
|
||||
// Error may be retryable - e.g. network error or temporary server error.
|
||||
// Throwing an error here causes this call to be retried after a delay.
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(db, baseUrl, 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();
|
||||
}
|
||||
17
lib/providers/body_weight_powersync.dart
Normal file
17
lib/providers/body_weight_powersync.dart
Normal file
@@ -0,0 +1,17 @@
|
||||
// Helper to read body weight entries from PowerSync local database and convert to WeightEntry
|
||||
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:wger/models/body_weight/weight_entry.dart';
|
||||
import 'package:wger/models/powersync/schema.dart';
|
||||
import 'package:wger/powersync.dart' as ps;
|
||||
|
||||
final _log = Logger('body_weight_powersync');
|
||||
|
||||
/// Watch the body weight entries as a stream.
|
||||
Stream<List<WeightEntry>> watchBodyWeightEntries({
|
||||
Duration pollInterval = const Duration(seconds: 2),
|
||||
}) {
|
||||
return ps.db.watch('SELECT * FROM $tableBodyWeights ORDER BY id').map((results) {
|
||||
return results.map(WeightEntry.fromRow).toList(growable: false);
|
||||
});
|
||||
}
|
||||
@@ -47,13 +47,13 @@ class BodyWeightRepository {
|
||||
_logger.info('Repository: updating weight entry ${entry.id}');
|
||||
await baseProvider.patch(
|
||||
entry.toJson(),
|
||||
baseProvider.makeUrl(BODY_WEIGHT_URL, id: entry.id),
|
||||
baseProvider.makeUrl(BODY_WEIGHT_URL, id: int.parse(entry.id!)),
|
||||
);
|
||||
}
|
||||
|
||||
/// Deletes a weight entry on the server
|
||||
Future<Response> delete(int id) async {
|
||||
Future<Response> delete(String id) async {
|
||||
_logger.info('Repository: deleting weight entry $id');
|
||||
return await baseProvider.deleteRequest(BODY_WEIGHT_URL, id);
|
||||
return await baseProvider.deleteRequest(BODY_WEIGHT_URL, int.parse(id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:wger/models/body_weight/weight_entry.dart';
|
||||
import 'package:wger/providers/auth.dart';
|
||||
import 'package:wger/providers/body_weight_powersync.dart';
|
||||
import 'package:wger/providers/body_weight_repository.dart';
|
||||
import 'package:wger/providers/body_weight_state.dart';
|
||||
import 'package:wger/providers/wger_base_riverpod.dart';
|
||||
@@ -23,3 +24,16 @@ final bodyWeightStateProvider =
|
||||
return BodyWeightState(repo);
|
||||
},
|
||||
);
|
||||
|
||||
/// StreamProvider that exposes live updates from the local PowerSync DB for
|
||||
/// body weight entries. Widgets can `ref.watch(bodyWeightStreamProvider(auth))`
|
||||
/// to get an `AsyncValue<List<WeightEntry>>` that updates whenever PowerSync
|
||||
/// reports changes.
|
||||
final bodyWeightStreamProvider = StreamProvider.family<List<WeightEntry>, AuthProvider>(
|
||||
(ref, auth) {
|
||||
// The stream itself is independent of Auth for now; auth is kept as a
|
||||
// family parameter for symmetry with other providers and potential
|
||||
// future auth-dependent logic (e.g. different DB instances per user).
|
||||
return watchBodyWeightEntries();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:wger/models/body_weight/weight_entry.dart';
|
||||
@@ -59,7 +61,7 @@ class BodyWeightState extends StateNotifier<List<WeightEntry>> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteEntry(int id) async {
|
||||
Future<void> deleteEntry(String id) async {
|
||||
final idx = state.indexWhere((e) => e.id == id);
|
||||
if (idx < 0) {
|
||||
return;
|
||||
|
||||
@@ -23,8 +23,8 @@ import 'package:logging/logging.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:rive/rive.dart';
|
||||
import 'package:wger/l10n/generated/app_localizations.dart';
|
||||
import 'package:wger/powersync.dart';
|
||||
import 'package:wger/providers/auth.dart';
|
||||
import 'package:wger/providers/body_weight_riverpod.dart';
|
||||
import 'package:wger/providers/exercises.dart';
|
||||
import 'package:wger/providers/gallery.dart';
|
||||
import 'package:wger/providers/measurement.dart';
|
||||
@@ -57,6 +57,10 @@ class _HomeTabsScreenState extends riverpod.ConsumerState<HomeTabsScreen>
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// do we need to await this? or if it's async, how do we handle failures?
|
||||
_setupPowersync();
|
||||
|
||||
// Loading data here, since the build method can be called more than once
|
||||
_initialData = _loadEntries();
|
||||
}
|
||||
@@ -67,6 +71,25 @@ class _HomeTabsScreenState extends riverpod.ConsumerState<HomeTabsScreen>
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _setupPowersync() async {
|
||||
final authProvider = context.read<AuthProvider>();
|
||||
final baseUrl = authProvider.serverUrl!;
|
||||
final powerSyncUrl = baseUrl.replaceAll(':8000', ':8080');
|
||||
|
||||
await openDatabase(false, baseUrl, powerSyncUrl);
|
||||
|
||||
final connector = DjangoConnector(db, baseUrl, powerSyncUrl);
|
||||
// try {
|
||||
// TODO: should we cache these credentials? that's what their demo does?
|
||||
// we could maybe get the initial token from the /api/v2/login call
|
||||
final credentials = await connector.fetchCredentials();
|
||||
widget._logger.fine('fetched credentials: $credentials');
|
||||
await openDatabase(true, baseUrl, powerSyncUrl);
|
||||
// } catch (e) {
|
||||
// widget._logger.warning('failed to fetchCredentials: $e');
|
||||
// }/
|
||||
}
|
||||
|
||||
final _screenList = [
|
||||
const DashboardScreen(),
|
||||
const RoutineListScreen(),
|
||||
@@ -119,7 +142,7 @@ class _HomeTabsScreenState extends riverpod.ConsumerState<HomeTabsScreen>
|
||||
measurementProvider.fetchAndSetAllCategoriesAndEntries(),
|
||||
]);
|
||||
// fetch weights separately using Riverpod notifier
|
||||
await ref.read(bodyWeightStateProvider(authProvider).notifier).fetchAndSetEntries();
|
||||
// await ref.read(bodyWeightStateProvider(authProvider).notifier).fetchAndSetEntries();
|
||||
|
||||
//
|
||||
// Current nutritional plan
|
||||
|
||||
@@ -40,99 +40,124 @@ class WeightOverview extends riverpod.ConsumerWidget {
|
||||
final plans = context.read<NutritionPlansProvider>().items;
|
||||
|
||||
final auth = context.read<AuthProvider>();
|
||||
final entriesList = ref.watch(bodyWeightStateProvider(auth));
|
||||
final entriesAll = entriesList.map((e) => MeasurementChartEntry(e.weight, e.date)).toList();
|
||||
final entries7dAvg = moving7dAverage(entriesAll);
|
||||
final entriesAsync = ref.watch(bodyWeightStreamProvider(auth));
|
||||
|
||||
final unit = weightUnit(profile!.isMetric, context);
|
||||
// Handle stream states (loading/error/data) and reuse the existing UI
|
||||
return entriesAsync.when(
|
||||
data: (entriesList) {
|
||||
final entriesAll = entriesList.map((e) => MeasurementChartEntry(e.weight, e.date)).toList();
|
||||
final entries7dAvg = moving7dAverage(entriesAll);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
...getOverviewWidgetsSeries(
|
||||
AppLocalizations.of(context).weight,
|
||||
entriesAll,
|
||||
entries7dAvg,
|
||||
plans,
|
||||
unit,
|
||||
context,
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pushNamed(
|
||||
context,
|
||||
'/measurement-categories',
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
Text(AppLocalizations.of(context).measurements),
|
||||
const Icon(Icons.chevron_right),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 300,
|
||||
child: RefreshIndicator(
|
||||
onRefresh: () => ref.read(bodyWeightStateProvider(auth).notifier).fetchAndSetEntries(),
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.all(10.0),
|
||||
itemCount: entriesList.length,
|
||||
itemBuilder: (context, index) {
|
||||
final currentEntry = entriesList[index];
|
||||
return Card(
|
||||
child: ListTile(
|
||||
title: Text(
|
||||
'${numberFormat.format(currentEntry.weight)} ${weightUnit(profile.isMetric, context)}',
|
||||
),
|
||||
subtitle: Text(
|
||||
DateFormat.yMd(
|
||||
Localizations.localeOf(context).languageCode,
|
||||
).add_Hm().format(currentEntry.date),
|
||||
),
|
||||
trailing: PopupMenuButton(
|
||||
itemBuilder: (BuildContext context) {
|
||||
return [
|
||||
PopupMenuItem(
|
||||
child: Text(AppLocalizations.of(context).edit),
|
||||
onTap: () => Navigator.pushNamed(
|
||||
context,
|
||||
FormScreen.routeName,
|
||||
arguments: FormScreenArguments(
|
||||
AppLocalizations.of(context).edit,
|
||||
WeightForm(currentEntry),
|
||||
),
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: Text(AppLocalizations.of(context).delete),
|
||||
onTap: () async {
|
||||
// Delete entry from DB
|
||||
await ref
|
||||
.read(bodyWeightStateProvider(auth).notifier)
|
||||
.deleteEntry(currentEntry.id!);
|
||||
final unit = weightUnit(profile!.isMetric, context);
|
||||
|
||||
// and inform the user
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
AppLocalizations.of(context).successfullyDeleted,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
];
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
return Column(
|
||||
children: [
|
||||
...getOverviewWidgetsSeries(
|
||||
AppLocalizations.of(context).weight,
|
||||
entriesAll,
|
||||
entries7dAvg,
|
||||
plans,
|
||||
unit,
|
||||
context,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pushNamed(
|
||||
context,
|
||||
'/measurement-categories',
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
Text(AppLocalizations.of(context).measurements),
|
||||
const Icon(Icons.chevron_right),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 300,
|
||||
child: RefreshIndicator(
|
||||
onRefresh: () =>
|
||||
ref.read(bodyWeightStateProvider(auth).notifier).fetchAndSetEntries(),
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.all(10.0),
|
||||
itemCount: entriesList.length,
|
||||
itemBuilder: (context, index) {
|
||||
final currentEntry = entriesList[index];
|
||||
return Card(
|
||||
child: ListTile(
|
||||
title: Text(
|
||||
'${numberFormat.format(currentEntry.weight)} ${weightUnit(profile.isMetric, context)}',
|
||||
),
|
||||
subtitle: Text(
|
||||
DateFormat.yMd(
|
||||
Localizations.localeOf(context).languageCode,
|
||||
).add_Hm().format(currentEntry.date),
|
||||
),
|
||||
trailing: PopupMenuButton(
|
||||
itemBuilder: (BuildContext context) {
|
||||
return [
|
||||
PopupMenuItem(
|
||||
child: Text(AppLocalizations.of(context).edit),
|
||||
onTap: () => Navigator.pushNamed(
|
||||
context,
|
||||
FormScreen.routeName,
|
||||
arguments: FormScreenArguments(
|
||||
AppLocalizations.of(context).edit,
|
||||
WeightForm(currentEntry),
|
||||
),
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: Text(AppLocalizations.of(context).delete),
|
||||
onTap: () async {
|
||||
// Delete entry from DB
|
||||
await ref
|
||||
.read(bodyWeightStateProvider(auth).notifier)
|
||||
.deleteEntry(currentEntry.id!);
|
||||
|
||||
// and inform the user
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
AppLocalizations.of(context).successfullyDeleted,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
];
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
loading: () {
|
||||
// Show a small loading indicator while waiting for the first data set
|
||||
return const Column(
|
||||
children: [
|
||||
SizedBox(height: 200, child: Center(child: CircularProgressIndicator())),
|
||||
],
|
||||
);
|
||||
},
|
||||
error: (err, st) {
|
||||
return Column(
|
||||
children: [
|
||||
Text('Error loading local weights: $err'),
|
||||
Text(st.toString()),
|
||||
|
||||
const SizedBox(height: 200, child: Center(child: Icon(Icons.error))),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <file_selector_linux/file_selector_plugin.h>
|
||||
#include <powersync_flutter_libs/powersync_flutter_libs_plugin.h>
|
||||
#include <rive_common/rive_plugin.h>
|
||||
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
|
||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||
@@ -15,6 +16,9 @@ void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
|
||||
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
|
||||
g_autoptr(FlPluginRegistrar) powersync_flutter_libs_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "PowersyncFlutterLibsPlugin");
|
||||
powersync_flutter_libs_plugin_register_with_registrar(powersync_flutter_libs_registrar);
|
||||
g_autoptr(FlPluginRegistrar) rive_common_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "RivePlugin");
|
||||
rive_plugin_register_with_registrar(rive_common_registrar);
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
file_selector_linux
|
||||
powersync_flutter_libs
|
||||
rive_common
|
||||
sqlite3_flutter_libs
|
||||
url_launcher_linux
|
||||
|
||||
@@ -8,6 +8,7 @@ import Foundation
|
||||
import file_selector_macos
|
||||
import package_info_plus
|
||||
import path_provider_foundation
|
||||
import powersync_flutter_libs
|
||||
import rive_common
|
||||
import shared_preferences_foundation
|
||||
import sqlite3_flutter_libs
|
||||
@@ -18,6 +19,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||
PowersyncFlutterLibsPlugin.register(with: registry.registrar(forPlugin: "PowersyncFlutterLibsPlugin"))
|
||||
RivePlugin.register(with: registry.registrar(forPlugin: "RivePlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin"))
|
||||
|
||||
68
pubspec.lock
68
pubspec.lock
@@ -828,6 +828,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.3"
|
||||
mutex:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: mutex
|
||||
sha256: "8827da25de792088eb33e572115a5eb0d61d61a3c01acbc8bcbe76ed78f1a1f2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.0"
|
||||
nested:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1004,6 +1012,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.2"
|
||||
powersync:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: powersync
|
||||
sha256: "3b2694bd3b5846b445dcdfcb2b17a6dfe81e0e97d8ed531d9211478587bfda31"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.16.1"
|
||||
powersync_core:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: powersync_core
|
||||
sha256: "878f335489bffa7f92167b42ce4ac2c0895015387a74285d4f2f2a11d47e08d2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.6.1"
|
||||
powersync_flutter_libs:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: powersync_flutter_libs
|
||||
sha256: "6385c74c3537b72086f62becd038e1f4a50bff57783e260de95f89f1c2a16aa2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.4.12"
|
||||
process:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1185,14 +1217,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.1"
|
||||
sprintf:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sprintf
|
||||
sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.0"
|
||||
sqlite3:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqlite3
|
||||
sha256: "310af39c40dd0bb2058538333c9d9840a2725ae0b9f77e4fd09ad6696aa8f66e"
|
||||
sha256: f18fd9a72d7a1ad2920db61368f2a69368f1cc9b56b8233e9d83b47b0a8435aa
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.7.5"
|
||||
version: "2.9.3"
|
||||
sqlite3_flutter_libs:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1201,6 +1241,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.40"
|
||||
sqlite3_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqlite3_web
|
||||
sha256: "0f6ebcb4992d1892ac5c8b5ecd22a458ab9c5eb6428b11ae5ecb5d63545844da"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.2"
|
||||
sqlite_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqlite_async
|
||||
sha256: cf1871971324cccc03d26762ff10406fc6384af9a1ea538f12352aa5906164ec
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.2"
|
||||
sqlparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1353,6 +1409,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.4"
|
||||
uuid:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: uuid
|
||||
sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.5.1"
|
||||
vector_graphics:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -68,6 +68,7 @@ dependencies:
|
||||
video_player: ^2.10.0
|
||||
logging: ^1.3.0
|
||||
flutter_riverpod: ^2.6.1
|
||||
powersync: ^1.16.1
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
@@ -23,19 +23,19 @@ void main() {
|
||||
group('fetchPost', () {
|
||||
test('Test that the weight entries are correctly converted to json', () {
|
||||
expect(
|
||||
WeightEntry(id: 1, weight: 80, date: DateTime(2020, 12, 31, 12, 34)).toJson(),
|
||||
WeightEntry(id: '1', weight: 80, date: DateTime(2020, 12, 31, 12, 34)).toJson(),
|
||||
{'id': 1, 'weight': '80', 'date': '2020-12-31T12:34:00.000'},
|
||||
);
|
||||
|
||||
expect(
|
||||
WeightEntry(id: 2, weight: 70.2, date: DateTime(2020, 12, 01)).toJson(),
|
||||
WeightEntry(id: '2', weight: 70.2, date: DateTime(2020, 12, 01)).toJson(),
|
||||
{'id': 2, 'weight': '70.2', 'date': '2020-12-01T00:00:00.000'},
|
||||
);
|
||||
});
|
||||
|
||||
test('Test that the weight entries are correctly converted from json', () {
|
||||
final WeightEntry weightEntryObj = WeightEntry(
|
||||
id: 1,
|
||||
id: '1',
|
||||
weight: 80,
|
||||
date: DateTime(2020, 12, 31),
|
||||
);
|
||||
@@ -53,8 +53,7 @@ void main() {
|
||||
group('model', () {
|
||||
test('Test the individual values from the model', () {
|
||||
WeightEntry weightModel;
|
||||
//_weightModel = WeightEntry();
|
||||
weightModel = WeightEntry(id: 1, weight: 80, date: DateTime(2020, 10, 01));
|
||||
weightModel = WeightEntry(id: '1', weight: 80, date: DateTime(2020, 10, 01));
|
||||
|
||||
expect(weightModel.id, 1);
|
||||
expect(weightModel.weight, 80);
|
||||
|
||||
@@ -107,11 +107,11 @@ void main() {
|
||||
final BodyWeightRepository repo = BodyWeightRepository(mockBaseProvider);
|
||||
final BodyWeightState provider = BodyWeightState(repo);
|
||||
provider.state = [
|
||||
WeightEntry(id: 4, weight: 80, date: DateTime(2021, 1, 1)),
|
||||
WeightEntry(id: 2, weight: 100, date: DateTime(2021, 2, 2)),
|
||||
WeightEntry(id: 5, weight: 60, date: DateTime(2021, 2, 2)),
|
||||
WeightEntry(id: '4', weight: 80, date: DateTime(2021, 1, 1)),
|
||||
WeightEntry(id: '2', weight: 100, date: DateTime(2021, 2, 2)),
|
||||
WeightEntry(id: '5', weight: 60, date: DateTime(2021, 2, 2)),
|
||||
];
|
||||
await provider.deleteEntry(4);
|
||||
await provider.deleteEntry('4');
|
||||
|
||||
// Check that the entry was removed from the entry list
|
||||
expect(provider.items.length, 2);
|
||||
|
||||
@@ -147,7 +147,7 @@ class FakeBodyWeightRepository extends BodyWeightRepository {
|
||||
FakeBodyWeightRepository(WgerBaseProvider base) : super(base);
|
||||
|
||||
@override
|
||||
Future<Response> delete(int id) async {
|
||||
Future<Response> delete(String id) async {
|
||||
return Response('', 204);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
|
||||
import 'package:wger/models/body_weight/weight_entry.dart';
|
||||
|
||||
final testWeightEntry1 = WeightEntry(id: 1, weight: 80, date: DateTime(2021, 01, 01, 15, 30));
|
||||
final testWeightEntry2 = WeightEntry(id: 2, weight: 81, date: DateTime(2021, 01, 10, 10, 0));
|
||||
final testWeightEntry1 = WeightEntry(id: '1', weight: 80, date: DateTime(2021, 01, 01, 15, 30));
|
||||
final testWeightEntry2 = WeightEntry(id: '2', weight: 81, date: DateTime(2021, 01, 10, 10, 0));
|
||||
|
||||
List<WeightEntry> getWeightEntries() {
|
||||
return [testWeightEntry1, testWeightEntry2];
|
||||
@@ -27,20 +27,20 @@ List<WeightEntry> getWeightEntries() {
|
||||
|
||||
List<WeightEntry> getScreenshotWeightEntries() {
|
||||
return [
|
||||
WeightEntry(id: 1, weight: 86, date: DateTime(2021, 01, 01)),
|
||||
WeightEntry(id: 2, weight: 81, date: DateTime(2021, 01, 10)),
|
||||
WeightEntry(id: 3, weight: 82, date: DateTime(2021, 01, 20)),
|
||||
WeightEntry(id: 4, weight: 83, date: DateTime(2021, 01, 30)),
|
||||
WeightEntry(id: 5, weight: 86, date: DateTime(2021, 02, 20)),
|
||||
WeightEntry(id: 6, weight: 90, date: DateTime(2021, 02, 28)),
|
||||
WeightEntry(id: 7, weight: 91, date: DateTime(2021, 03, 20)),
|
||||
WeightEntry(id: 8, weight: 91.1, date: DateTime(2021, 03, 30)),
|
||||
WeightEntry(id: 9, weight: 90, date: DateTime(2021, 05, 1)),
|
||||
WeightEntry(id: 10, weight: 91, date: DateTime(2021, 6, 5)),
|
||||
WeightEntry(id: 11, weight: 89, date: DateTime(2021, 6, 20)),
|
||||
WeightEntry(id: 12, weight: 88, date: DateTime(2021, 7, 15)),
|
||||
WeightEntry(id: 13, weight: 86, date: DateTime(2021, 7, 20)),
|
||||
WeightEntry(id: 14, weight: 83, date: DateTime(2021, 7, 30)),
|
||||
WeightEntry(id: 15, weight: 80, date: DateTime(2021, 8, 10)),
|
||||
WeightEntry(id: '1', weight: 86, date: DateTime(2021, 01, 01)),
|
||||
WeightEntry(id: '2', weight: 81, date: DateTime(2021, 01, 10)),
|
||||
WeightEntry(id: '3', weight: 82, date: DateTime(2021, 01, 20)),
|
||||
WeightEntry(id: '4', weight: 83, date: DateTime(2021, 01, 30)),
|
||||
WeightEntry(id: '5', weight: 86, date: DateTime(2021, 02, 20)),
|
||||
WeightEntry(id: '6', weight: 90, date: DateTime(2021, 02, 28)),
|
||||
WeightEntry(id: '7', weight: 91, date: DateTime(2021, 03, 20)),
|
||||
WeightEntry(id: '8', weight: 91.1, date: DateTime(2021, 03, 30)),
|
||||
WeightEntry(id: '9', weight: 90, date: DateTime(2021, 05, 1)),
|
||||
WeightEntry(id: '10', weight: 91, date: DateTime(2021, 6, 5)),
|
||||
WeightEntry(id: '11', weight: 89, date: DateTime(2021, 6, 20)),
|
||||
WeightEntry(id: '12', weight: 88, date: DateTime(2021, 7, 15)),
|
||||
WeightEntry(id: '13', weight: 86, date: DateTime(2021, 7, 20)),
|
||||
WeightEntry(id: '14', weight: 83, date: DateTime(2021, 7, 30)),
|
||||
WeightEntry(id: '15', weight: 80, date: DateTime(2021, 8, 10)),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <file_selector_windows/file_selector_windows.h>
|
||||
#include <powersync_flutter_libs/powersync_flutter_libs_plugin.h>
|
||||
#include <rive_common/rive_plugin.h>
|
||||
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
|
||||
#include <url_launcher_windows/url_launcher_windows.h>
|
||||
@@ -14,6 +15,8 @@
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
FileSelectorWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
||||
PowersyncFlutterLibsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("PowersyncFlutterLibsPlugin"));
|
||||
RivePluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("RivePlugin"));
|
||||
Sqlite3FlutterLibsPluginRegisterWithRegistrar(
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
file_selector_windows
|
||||
powersync_flutter_libs
|
||||
rive_common
|
||||
sqlite3_flutter_libs
|
||||
url_launcher_windows
|
||||
|
||||
Reference in New Issue
Block a user