58 Commits
Author SHA1 Message Date
julian 52eb2fbab1 pubspec: add build number to distinguish apps in fdroid
Build and deploy the latest docker container / deploy (push) Successful in 1m11s
Build and release the latest android apk / rolling-release-apk (push) Failing after 6m3s
Build and release a versioned android apk / release-apk (push) Successful in 5m10s
2026-07-04 11:45:10 +02:00
julian 416979ab3d sheets view: add refresh button for annotations
Build and deploy the latest docker container / deploy (push) Successful in 1m22s
Build and release the latest android apk / rolling-release-apk (push) Successful in 5m25s
2026-07-04 11:26:04 +02:00
julian 984e32cd03 linux: regenerate to fix bugs after update 2026-07-04 11:14:50 +02:00
julian 125346668a upgrade everything, devenv, flutter, gradle
Build and deploy the latest docker container / deploy (push) Successful in 29m5s
Build and release the latest android apk / rolling-release-apk (push) Successful in 6m1s
2026-07-03 22:35:01 +02:00
julian 38eb1cccc5 pubspec: update
Build and deploy the latest docker container / deploy (push) Successful in 59s
Build and release the latest android apk / rolling-release-apk (push) Successful in 4m50s
2026-07-03 18:41:03 +02:00
julian 46c88df707 use wasm for web compilation, fixing pdfrx 2026-07-03 18:40:47 +02:00
julian c8f941a7e0 Only check for cached pdf files if not web
Build and deploy the latest docker container / deploy (push) Successful in 57s
Build and release the latest android apk / rolling-release-apk (push) Successful in 5m2s
2026-07-03 18:20:33 +02:00
julian 4d1dda0263 Save refresh token only if not null
Build and deploy the latest docker container / deploy (push) Successful in 57s
Build and release the latest android apk / rolling-release-apk (push) Successful in 4m55s
Build and release a versioned android apk / release-apk (push) Successful in 5m2s
2026-07-03 18:15:22 +02:00
julian 1c5df097c4 Make this version 0.2.1
Build and deploy the latest docker container / deploy (push) Successful in 1m6s
Build and release the latest android apk / rolling-release-apk (push) Successful in 5m31s
Build and release a versioned android apk / release-apk (push) Successful in 5m9s
2026-07-03 17:51:03 +02:00
julian 73336ce3fa Show offline symbol at first start if offline 2026-07-03 17:49:06 +02:00
julian baf20e1ca8 Add symbol for offline available sheets 2026-07-03 17:46:50 +02:00
julian e3ee24e06b Remove unnecessary log lines
Build and deploy the latest docker container / deploy (push) Successful in 2m6s
Build and release the latest android apk / rolling-release-apk (push) Failing after 18m49s
2026-07-02 22:46:52 +02:00
julian 2039080a92 auth: use sessions 2026-07-02 22:42:46 +02:00
julian a3069f0864 pubspec: update 2026-07-02 19:16:25 +02:00
julian c468321637 fix documents path not found on linux 2026-07-02 19:16:13 +02:00
julian 22aa22ce76 ci: correct apk location
Build and deploy the latest docker container / deploy (push) Successful in 51s
Build and release the latest android apk / rolling-release-apk (push) Successful in 4m53s
Build and release a versioned android apk / release-apk (push) Successful in 4m31s
2026-06-12 18:24:37 +02:00
julian d7d0668e29 Switch gradle home to local to fix gradle build in ci
Build and deploy the latest docker container / deploy (push) Successful in 46s
Build and release the latest android apk / rolling-release-apk (push) Successful in 4m13s
2026-06-12 09:07:38 +02:00
julian 76edfc5721 Remove jenkinsfile
Build and deploy the latest docker container / deploy (push) Successful in 46s
Build and release the latest android apk / rolling-release-apk (push) Failing after 12s
2026-06-12 09:00:54 +02:00
julian 99a79d8df9 devenv: remove unneeded deploy-android code
Build and deploy the latest docker container / deploy (push) Successful in 53s
Build and release the latest android apk / rolling-release-apk (push) Failing after 11s
2026-06-12 09:00:27 +02:00
julian 3affa455d0 ci: rework workflows to add one for android 2026-06-12 09:00:10 +02:00
julian 961a8d3918 ci: use nixos runner
Build and Deploy / deploy (push) Successful in 38m41s
2026-06-11 21:22:29 +02:00
julian 761372fb03 Move deploy to deploy-docker.yml ci workflow 2026-06-11 20:40:02 +02:00
julian 3f35c670ca Add gitea ci workflow
Build and Deploy / deploy (push) Failing after 37m6s
2026-06-04 18:14:53 +02:00
julian a32bfd5108 Update devenv.lock 2026-06-04 18:14:45 +02:00
julian 9261ec341e Add sync status to app drawer 2026-02-06 20:43:26 +01:00
julian 7ec7b20e74 Update version 2026-02-06 20:19:32 +01:00
julian 829bf7512a Only call setFullScreen if that changes fullscreen state 2026-02-06 20:16:00 +01:00
julian 4fe8896f9e Also upload annotations if page was cleared 2026-02-06 20:11:54 +01:00
julian d3addc7973 Move to jdk 17 2026-02-06 19:34:20 +01:00
julian f1b1ccf6be Update flutter dependencies 2026-02-06 19:34:09 +01:00
julian 99300478d5 Update agp version 2026-02-06 19:34:01 +01:00
julian f6585e9850 Force newest ndk version for all subprojects 2026-02-06 19:33:42 +01:00
julian 16589be409 Allow composer name to be empty 2026-02-06 17:38:13 +01:00
julian 6669e2446c Implement clearing user data on logout 2026-02-06 17:31:04 +01:00
julian b62ed98375 Fix offline symbol on AppBar 2026-02-06 17:30:46 +01:00
julian a70c634d35 Format code 2026-02-06 16:52:00 +01:00
julian 0fdf56c084 Remove unneeded function 2026-02-06 16:51:54 +01:00
julian a57831f50b Finalize switch from composerName to composer in Sheet model 2026-02-06 16:51:29 +01:00
julian 8f05e9244a Remove deprecated option from FlutterSecureStorage 2026-02-06 16:48:30 +01:00
julian d01e1384d4 Sheet model: adapt to reduced data model of server 2026-02-06 16:47:04 +01:00
julian d0fd96a2f5 Implement offline mode 2026-02-06 16:41:58 +01:00
julian 58157a2e6e Only save annotations on changes 2026-02-06 16:09:52 +01:00
julian 9a11e42571 Implement annotation syncing to and from server 2026-02-06 16:05:55 +01:00
julian e5c71c9261 Reformat code 2026-02-06 15:54:09 +01:00
julian 4fd287181b Only save on exit if in paint mode 2026-02-06 15:15:59 +01:00
julian 181e853790 Remove unneeded function 2026-02-06 15:14:43 +01:00
julian d94e9eeb3d Allow free zoom and pan in drawing mode 2026-02-05 18:47:28 +01:00
julian d1b5cb54f4 Add eraser 2026-02-05 18:41:43 +01:00
julian b36011d9e8 Add more tools to the drawing toolbar 2026-02-05 18:31:58 +01:00
julian 421171f1a3 Remove unused class 2026-02-05 18:23:41 +01:00
julian 3b12be497e Avoid too close points being added to a drawing_line 2026-02-05 18:23:28 +01:00
julian f615ed5654 Remove unneeded child 2026-02-05 17:59:25 +01:00
julian d4d6e41a9d Custom drawing implementation 2026-02-05 17:55:26 +01:00
julian e1d72de718 Use drawing controller 2026-02-04 14:00:32 +01:00
julian 93127cada8 Update dependencies 2026-02-04 13:53:33 +01:00
julian d626271ad2 Improve error messages for impossible actions during paint mode 2026-02-04 11:50:27 +01:00
julian 704bd0b928 Complete refactor to clean up project 2026-02-04 11:36:02 +01:00
julian 4f380b5444 Change default url to sheetless.julian-mutter.de 2026-02-04 10:33:05 +01:00
63 changed files with 4860 additions and 1985 deletions
+21
View File
@@ -0,0 +1,21 @@
name: Build and deploy the latest docker container
on:
push:
branches:
- master
jobs:
deploy:
runs-on: nixos
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Build docker
run: devenv shell build-docker
- name: Deploy docker
env:
REGISTRY_USERNAME: ${{ secrets.CONTAINER_REGISTRY_GITEA_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.CONTAINER_REGISTRY_GITEA_PASSWORD }}
run: devenv shell deploy-docker
@@ -0,0 +1,37 @@
name: Build and release the latest android apk
on:
push:
branches:
- master
jobs:
rolling-release-apk:
runs-on: nixos
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Build apk
run: devenv shell build-android
- name: Move latest Tag
run: |
git config user.name "gitea-actions"
git config user.email "gitea-actions@localhost"
# Forcefully creates/moves the 'latest' tag to the current commit
git tag -f latest
# Force-pushes the updated tag back to your Gitea repository
git push origin -f latest
- name: Update Gitea Release & Swap APK
uses: akkuman/gitea-release-action@v1
env:
NODE_OPTIONS: '--experimental-fetch'
with:
tag_name: latest # Explicitly target the tag we just moved
name: 'Latest Development Build'
body: 'This rolling release is automatically generated and always contains the newest code from the main branch.'
files: build/app/outputs/flutter-apk/*.apk
token: ${{ secrets.GITHUB_TOKEN }}
@@ -0,0 +1,23 @@
name: Build and release a versioned android apk
on:
push:
tags:
- 'v*'
jobs:
release-apk:
runs-on: nixos
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Build apk
run: devenv shell build-android
- name: Create Gitea Release & Upload APK
uses: akkuman/gitea-release-action@v1
env:
NODE_OPTIONS: '--experimental-fetch' # if nodejs < 18
with:
files: build/app/outputs/flutter-apk/*.apk
token: ${{ secrets.GITHUB_TOKEN }}
+1
View File
@@ -56,3 +56,4 @@ devenv.local.nix
.android .android
android/app/.cxx/ android/app/.cxx/
/.gradle/ /.gradle/
/.gitea/workflows/.gradle/
-15
View File
@@ -15,24 +15,9 @@ migration:
- platform: root - platform: root
create_revision: nixpkgs000000000000000000000000000000000 create_revision: nixpkgs000000000000000000000000000000000
base_revision: nixpkgs000000000000000000000000000000000 base_revision: nixpkgs000000000000000000000000000000000
- platform: android
create_revision: nixpkgs000000000000000000000000000000000
base_revision: nixpkgs000000000000000000000000000000000
- platform: ios
create_revision: nixpkgs000000000000000000000000000000000
base_revision: nixpkgs000000000000000000000000000000000
- platform: linux - platform: linux
create_revision: nixpkgs000000000000000000000000000000000 create_revision: nixpkgs000000000000000000000000000000000
base_revision: nixpkgs000000000000000000000000000000000 base_revision: nixpkgs000000000000000000000000000000000
- platform: macos
create_revision: nixpkgs000000000000000000000000000000000
base_revision: nixpkgs000000000000000000000000000000000
- platform: web
create_revision: nixpkgs000000000000000000000000000000000
base_revision: nixpkgs000000000000000000000000000000000
- platform: windows
create_revision: nixpkgs000000000000000000000000000000000
base_revision: nixpkgs000000000000000000000000000000000
# User provided section # User provided section
Vendored
-93
View File
@@ -1,93 +0,0 @@
pipeline {
agent {
node {
label 'devenv'
}
}
triggers {
pollSCM('H/10 * * * *')
}
stages {
stage('Test devenv') {
steps {
sh 'devenv test'
}
}
stage('Flutter builds') {
steps {
sh 'devenv shell dart run pdfrx:remove_wasm_modules --revert' // Make sure WASM is put in output
sh 'devenv shell flutter build web --release'
sh 'devenv shell dart run pdfrx:remove_wasm_modules' // NOT idempotent!
sh 'devenv shell flutter build apk --release'
sh 'devenv shell dart run pdfrx:remove_wasm_modules --revert' // Reset again, just to be sure
}
}
stage('Parallel Deploy') {
parallel {
stage('Web') {
steps {
sh 'docker build -t harbor.julian-mutter.de/sheetless/sheetless-frontend .'
withDockerRegistry([credentialsId: 'harbor', url: 'https://harbor.julian-mutter.de']) {
sh 'docker push harbor.julian-mutter.de/sheetless/sheetless-frontend'
}
}
}
stage('Android') {
stages {
stage('Checkout F-Droid Repo') {
steps {
dir("fdroid-repo") {
git branch: 'master', url: "https://gitlab.julian-mutter.de/julian/fdroid-frajul.git", credentialsId: "gitea", poll: "false"
}
}
}
stage('Decrypt keys') {
steps {
dir("fdroid-repo") {
sh 'devenv shell ./decrypt.sh'
}
}
}
stage('Copy APK') {
steps {
sh "cp build/app/outputs/apk/release/app-release.apk fdroid-repo/fdroid/repo/sheetless.apk"
}
}
stage('Update Metadata') {
steps {
dir("fdroid-repo") {
sh 'devenv shell ./update-repo.sh'
}
}
}
stage('Push updates') {
steps {
dir("fdroid-repo") {
withCredentials([gitUsernamePassword(credentialsId: 'gitea')]) {
sh '''
git config --global user.email "jenkins@comumail.de"
git config --global user.name "Jenkins CI"
./push-updates.sh
'''
}
}
}
}
}
}
}
}
}
post {
always {
step([$class: 'Mailer', notifyEveryUnstableBuild: true, recipients: emailextrecipients([developers(), requestor()])])
cleanWs()
}
}
}
+3 -3
View File
@@ -11,12 +11,12 @@ android {
ndkVersion = "27.2.12479018" ndkVersion = "27.2.12479018"
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_17
} }
kotlinOptions { kotlinOptions {
jvmTarget = JavaVersion.VERSION_11.toString() jvmTarget = JavaVersion.VERSION_17.toString()
} }
defaultConfig { defaultConfig {
+11
View File
@@ -1,3 +1,13 @@
subprojects {
afterEvaluate {
val android = project.extensions.findByName("android")
if (android is com.android.build.gradle.BaseExtension) {
// Force the same sdk version everywhere
android.ndkVersion = "29.0.14206865"
}
}
}
allprojects { allprojects {
repositories { repositories {
google() google()
@@ -19,3 +29,4 @@ subprojects {
tasks.register<Delete>("clean") { tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory) delete(rootProject.layout.buildDirectory)
} }
File diff suppressed because one or more lines are too long
+4
View File
@@ -1,3 +1,7 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true android.useAndroidX=true
android.enableJetifier=true android.enableJetifier=true
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=false
+1 -1
View File
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.4-all.zip
+2 -2
View File
@@ -18,8 +18,8 @@ pluginManagement {
plugins { plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.7.3" apply false id("com.android.application") version "8.12.0" apply false
id("org.jetbrains.kotlin.android") version "2.1.0" apply false id("org.jetbrains.kotlin.android") version "2.2.20" apply false
} }
include(":app") include(":app")
+7 -65
View File
@@ -3,10 +3,11 @@
"devenv": { "devenv": {
"locked": { "locked": {
"dir": "src/modules", "dir": "src/modules",
"lastModified": 1753981111, "lastModified": 1783049965,
"narHash": "sha256-z2QvrvE+gzLnpqfLpQA4aq2gAB4xn6C5q2o1l7GBMXU=",
"owner": "cachix", "owner": "cachix",
"repo": "devenv", "repo": "devenv",
"rev": "d4d70df706b153b601a87ab8e81c88a0b1a373b6", "rev": "afed7bf3240c70d2827569568d2c46c25a64db51",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -16,68 +17,13 @@
"type": "github" "type": "github"
} }
}, },
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1747046372,
"owner": "edolstra",
"repo": "flake-compat",
"rev": "9100a0f413b0c601e0533d1d94ffd501ce2e7885",
"type": "github"
},
"original": {
"owner": "edolstra",
"repo": "flake-compat",
"type": "github"
}
},
"git-hooks": {
"inputs": {
"flake-compat": "flake-compat",
"gitignore": "gitignore",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1750779888,
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "16ec914f6fb6f599ce988427d9d94efddf25fe6d",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "git-hooks.nix",
"type": "github"
}
},
"gitignore": {
"inputs": {
"nixpkgs": [
"git-hooks",
"nixpkgs"
]
},
"locked": {
"lastModified": 1709087332,
"owner": "hercules-ci",
"repo": "gitignore.nix",
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "gitignore.nix",
"type": "github"
}
},
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1753939845, "lastModified": 1782723713,
"narHash": "sha256-oPXCU/SSUokcGaJREHibG1CBX3+s/W7orDWQOZDsEeQ=",
"owner": "nixos", "owner": "nixos",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "94def634a20494ee057c76998843c015909d6311", "rev": "b5aa0fbd538984f6e3d201be0005b4463d8b09f8",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -90,11 +36,7 @@
"root": { "root": {
"inputs": { "inputs": {
"devenv": "devenv", "devenv": "devenv",
"git-hooks": "git-hooks", "nixpkgs": "nixpkgs"
"nixpkgs": "nixpkgs",
"pre-commit-hooks": [
"git-hooks"
]
} }
} }
}, },
+37 -14
View File
@@ -1,5 +1,4 @@
{ pkgs, ... }: { pkgs, ... }: {
{
android = { android = {
enable = true; enable = true;
emulator.enable = false; emulator.enable = false;
@@ -7,23 +6,25 @@
flutter.package = pkgs.flutter; flutter.package = pkgs.flutter;
buildTools.version = [ buildTools.version = [
# Put version to compile with first! # Put the latest version to compile with first!
"36.0.0" # Android 16/17 target
"35.0.0" "35.0.0"
"33.0.1"
"34.0.0" "34.0.0"
]; ];
platforms.version = [ platforms.version = [
"36" "37" # Android 17
"35" "36" # Android 16
"34" "35" # Android 15
]; ];
ndk.enable = true; ndk.enable = true;
ndk.version = [ ndk.version = [
"27.2.12479018" "29.0.14206865" # NDK r29 (Latest Stable)
"27.0.12077973" "27.3.13750724" # NDK r27d (Latest LTS)
"26.3.11579264" "27.2.12479018" # Kept for backward compatibility
"25.1.8937393"
]; ];
extraLicenses = [ extraLicenses = [
"android-googlexr-license" "android-googlexr-license"
"android-sdk-preview-license" "android-sdk-preview-license"
@@ -39,6 +40,9 @@
packages = with pkgs; [ packages = with pkgs; [
# Needed by flutter_secure_storage # Needed by flutter_secure_storage
libsecret.dev libsecret.dev
# Needed by path_provider on linux
xdg-user-dirs
]; ];
processes.web-server = { processes.web-server = {
@@ -46,10 +50,29 @@
}; };
scripts = { scripts = {
build-docker.exec = ''
dart run pdfrx:remove_wasm_modules --revert // Make sure WASM is put in output
flutter build web --release --wasm
docker build -t gitlab.julian-mutter.de/julian/sheetless-frontend:latest .
'';
deploy-docker.exec = '' deploy-docker.exec = ''
flutter build web --release if [ -n "''${REGISTRY_USERNAME:-}" ] && [ -n "''${REGISTRY_PASSWORD:-}" ]; then
docker build -t harbor.julian-mutter.de/sheetless/sheetless-frontend . docker login gitlab.julian-mutter.de --username "$REGISTRY_USERNAME" --password "$REGISTRY_PASSWORD"
docker push harbor.julian-mutter.de/sheetless/sheetless-frontend fi
docker push gitlab.julian-mutter.de/julian/sheetless-frontend
'';
build-android.exec = ''
export GRADLE_USER_HOME="$(pwd)/.gradle"
dart run pdfrx:remove_wasm_modules --revert // Make sure WASM is put in output
dart run pdfrx:remove_wasm_modules // NOT idempotent!
flutter build apk --release
''; '';
}; };
} }
-163
View File
@@ -1,163 +0,0 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:logging/logging.dart';
import 'package:path_provider/path_provider.dart'; // For cache storage
import 'sheet.dart';
class ApiClient {
final log = Logger("ApiClient");
final String baseUrl;
String? token;
ApiClient({required this.baseUrl, this.token});
Future<void> login(String username, String password) async {
log.info("Logging in...");
final url = '$baseUrl/auth/login';
final response = await http.post(
Uri.parse(url),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'username': username, 'password': password}),
);
if (response.statusCode == 200) {
final responseData = jsonDecode(response.body);
token = responseData['token'];
log.info('Login successful');
} else {
throw Exception(
"Failed logging in: Response code ${response.statusCode}\nResponse: ${response.body}",
);
}
}
void logout() {
token = null;
log.info('Logged out successfully.');
}
Future<http.Response> get(
String endpoint, {
bool isBinary = false,
bool throwExceptionIfStatusCodeNot200 = false,
}) async {
final url = '$baseUrl$endpoint';
final headers = {
'Authorization': 'Bearer $token',
if (!isBinary) 'Content-Type': 'application/json',
};
final response = await http.get(Uri.parse(url), headers: headers);
if (!throwExceptionIfStatusCodeNot200 || response.statusCode == 200) {
return response;
} else {
log.warning(
"Failed get request to '$url'! StatusCode: ${response.statusCode}\nResponseBody: ${response.body}",
);
throw Exception(
'GET request failed: ${response.statusCode} ${response.body}',
);
}
}
Future<http.Response?> post(
String endpoint,
Map<String, dynamic> body,
) async {
try {
final url = '$baseUrl$endpoint';
final headers = {
'Authorization': 'Bearer $token',
'Content-Type': 'application/json',
};
final response = await http.post(
Uri.parse(url),
headers: headers,
body: jsonEncode(body),
);
if (response.statusCode == 200 || response.statusCode == 201) {
return response;
} else {
log.info(
'POST request failed: ${response.statusCode} ${response.body}',
);
}
} catch (e) {
log.info('Error during POST request: $e');
}
return null;
}
Future<http.Response?> postFormData(String endpoint, String body) async {
try {
final url = '$baseUrl$endpoint';
final headers = {
'Authorization': 'Bearer $token',
'Content-Type': 'application/x-www-form-urlencoded',
};
final response = await http.post(
Uri.parse(url),
headers: headers,
body: body,
);
if (response.statusCode == 200 || response.statusCode == 201) {
return response;
} else {
log.info(
'POST Form Data request failed: ${response.statusCode} ${response.body}',
);
}
} catch (e) {
log.info('Error during POST Form Data request: $e');
}
return null;
}
Future<List<Sheet>> fetchSheets() async {
final response = await get(
"/api/sheets/list",
throwExceptionIfStatusCodeNot200: true,
);
final data = jsonDecode(response.body);
return (data['sheets'] as List<dynamic>)
.map((sheet) => Sheet.fromJson(sheet as Map<String, dynamic>))
.toList();
}
Future<Uint8List> fetchPdfFileData(String sheetUuid) async {
final response = await get(
'/api/sheets/get/$sheetUuid',
isBinary: true,
throwExceptionIfStatusCodeNot200: true,
);
log.info("PDF downloaded");
return response.bodyBytes;
}
Future<File> getPdfFileCached(String sheetUuid) async {
final cacheDir = await getTemporaryDirectory();
final cachedPdfPath = '${cacheDir.path}/$sheetUuid.pdf';
final cachedFile = File(cachedPdfPath);
if (await cachedFile.exists()) {
log.info("PDF found in cache: $cachedPdfPath");
return cachedFile;
}
final pdfFileData = await fetchPdfFileData(sheetUuid);
await cachedFile.writeAsBytes(pdfFileData);
log.info("PDF cached at: $cachedPdfPath");
return cachedFile;
}
}
+23
View File
@@ -0,0 +1,23 @@
import 'package:flutter/material.dart';
import 'features/auth/login_page.dart';
/// Global route observer for tracking navigation events.
///
/// Used by pages that need to respond to navigation changes
final RouteObserver<ModalRoute> routeObserver = RouteObserver<ModalRoute>();
/// The main Sheetless application widget.
class SheetlessApp extends StatelessWidget {
const SheetlessApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Sheetless',
theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.blue),
home: const LoginPage(),
navigatorObservers: [routeObserver],
);
}
}
-42
View File
@@ -1,42 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class BtPedalShortcuts extends StatefulWidget {
final Widget child;
final VoidCallback? onTurnPageForward;
final VoidCallback? onTurnPageBackward;
const BtPedalShortcuts({
super.key,
required this.child,
this.onTurnPageForward,
this.onTurnPageBackward,
});
@override
State<BtPedalShortcuts> createState() => _BtPedalShortcutsState();
}
class _BtPedalShortcutsState extends State<BtPedalShortcuts> {
String lastAction = "Press pedal...";
@override
Widget build(BuildContext context) {
return CallbackShortcuts(
bindings: <ShortcutActivator, VoidCallback>{
// Shortcuts for page forward
const SingleActivator(LogicalKeyboardKey.arrowDown):
widget.onTurnPageForward ?? () => {},
const SingleActivator(LogicalKeyboardKey.arrowRight):
widget.onTurnPageForward ?? () => {},
// Shortcuts for page backward
const SingleActivator(LogicalKeyboardKey.arrowUp):
widget.onTurnPageBackward ?? () => {},
const SingleActivator(LogicalKeyboardKey.arrowLeft):
widget.onTurnPageBackward ?? () => {},
},
child: Focus(autofocus: true, child: widget.child),
);
}
}
+110
View File
@@ -0,0 +1,110 @@
import 'dart:collection';
import 'sheet.dart';
/// Types of changes that can be queued for server synchronization.
enum ChangeType {
sheetNameChange,
composerNameChange,
addTagChange,
removeTagChange,
}
/// Represents a single pending change to be synced with the server.
///
/// Changes are stored locally when offline and applied once
/// the device regains connectivity. Each change has a [createdAt] timestamp
/// that the server uses to resolve conflicts between devices.
class Change {
final ChangeType type;
final String sheetUuid;
final String value;
final DateTime createdAt;
Change({
required this.type,
required this.sheetUuid,
required this.value,
DateTime? createdAt,
}) : createdAt = createdAt ?? DateTime.now();
/// Serializes this change to a map for storage.
Map<String, dynamic> toMap() => {
'type': type.index,
'sheetUuid': sheetUuid,
'value': value,
'createdAt': createdAt.toIso8601String(),
};
/// Serializes this change to JSON for API requests.
Map<String, dynamic> toJson() => {
'type': type.name,
'sheetUuid': sheetUuid,
'value': value,
'createdAt': createdAt.toIso8601String(),
};
/// Deserializes a change from a stored map.
///
/// Note: Adding new [ChangeType] values may cause issues with
/// previously stored changes that use index-based serialization.
factory Change.fromMap(Map<dynamic, dynamic> map) {
return Change(
type: ChangeType.values[map['type']],
sheetUuid: map['sheetUuid'],
value: map['value'],
createdAt: map['createdAt'] != null
? DateTime.parse(map['createdAt'])
: DateTime.now(),
);
}
}
/// A queue of pending changes to be synchronized with the server.
///
/// Changes are stored in FIFO order (oldest first) and applied
/// to sheets in sequence when syncing.
class ChangeQueue {
final Queue<Change> _queue = Queue();
ChangeQueue();
/// Adds a change to the end of the queue.
void addChange(Change change) {
_queue.addLast(change);
}
/// Returns the number of pending changes.
int get length => _queue.length;
/// Whether the queue has any pending changes.
bool get isEmpty => _queue.isEmpty;
/// Whether the queue has pending changes.
bool get isNotEmpty => _queue.isNotEmpty;
/// Applies all queued changes to the provided list of sheets.
///
/// Each change modifies the corresponding sheet's properties
/// based on the change type.
void applyToSheets(List<Sheet> sheets) {
for (final change in _queue) {
final sheet = sheets.firstWhere(
(s) => s.uuid == change.sheetUuid,
orElse: () =>
throw StateError('Sheet with UUID ${change.sheetUuid} not found'),
);
switch (change.type) {
case ChangeType.sheetNameChange:
sheet.name = change.value;
case ChangeType.composerNameChange:
sheet.composer = change.value;
case ChangeType.addTagChange:
throw UnimplementedError('Tag support not yet implemented');
case ChangeType.removeTagChange:
throw UnimplementedError('Tag support not yet implemented');
}
}
}
}
+37
View File
@@ -0,0 +1,37 @@
/// Application configuration model.
///
/// Stores user preferences that are persisted across sessions,
/// such as display mode and fullscreen settings.
class Config {
/// Storage keys for persistence
static const String keyTwoPageMode = 'twoPageMode';
static const String keyFullscreen = 'fullscreen';
/// Whether to display two pages side-by-side (for tablets/landscape).
bool twoPageMode;
/// Whether the app is in fullscreen mode.
bool fullscreen;
Config({
required this.twoPageMode,
required this.fullscreen,
});
/// Creates a default configuration with all options disabled.
factory Config.defaultConfig() => Config(
twoPageMode: false,
fullscreen: false,
);
/// Creates a copy of this config with optional overrides.
Config copyWith({
bool? twoPageMode,
bool? fullscreen,
}) {
return Config(
twoPageMode: twoPageMode ?? this.twoPageMode,
fullscreen: fullscreen ?? this.fullscreen,
);
}
}
+37
View File
@@ -0,0 +1,37 @@
/// Data model representing a sheet music document.
///
/// A [Sheet] contains metadata about a piece of sheet music,
/// including its title, composer information, and timestamps.
class Sheet {
final String uuid;
String name;
String composer;
DateTime updatedAt;
bool availableOffline;
Sheet({
required this.uuid,
required this.name,
required this.composer,
required this.updatedAt,
this.availableOffline = false,
});
/// Creates a [Sheet] from a JSON map returned by the API.
factory Sheet.fromJson(Map<String, dynamic> json) {
return Sheet(
uuid: json['uuid'],
name: json['title'],
composer: json['composer'],
updatedAt: DateTime.parse(json['updated_at']),
);
}
/// Converts this sheet to a JSON map for API requests.
Map<String, dynamic> toJson() => {
'uuid': uuid,
'title': name,
'composer': composer,
'updated_at': updatedAt.toIso8601String(),
};
}
@@ -0,0 +1,152 @@
import 'package:logging/logging.dart';
import 'api_client.dart';
import 'storage_service.dart';
/// Service for synchronizing annotations between local storage and server.
///
/// Handles downloading annotations on sheet open and uploading on save,
/// comparing timestamps to determine which version is newer.
class AnnotationSyncService {
final _log = Logger('AnnotationSyncService');
final ApiClient _apiClient;
final StorageService _storageService;
AnnotationSyncService({
required ApiClient apiClient,
required StorageService storageService,
}) : _apiClient = apiClient,
_storageService = storageService;
/// Downloads annotations from server and merges with local storage.
///
/// For each page, compares server's lastModified with local lastModified.
/// If server is newer, overwrites local. Local annotations that are newer
/// are preserved.
Future<void> syncAnnotationsFromServer(String sheetUuid) async {
try {
_log.info('Syncing annotations from server for sheet $sheetUuid');
// Fetch all annotations from server
final serverAnnotations = await _apiClient.fetchAnnotations(sheetUuid);
_log.fine('Fetched annotations from server for sheet $sheetUuid');
// Get all local annotations with metadata
final localAnnotations = await _storageService
.readAllAnnotationsWithMetadata(sheetUuid);
int updatedCount = 0;
// Process each server annotation
for (final serverAnnotation in serverAnnotations) {
final page = serverAnnotation.page;
final localAnnotation = localAnnotations[page];
bool shouldUpdate = false;
if (localAnnotation == null) {
// No local annotation - use server version
shouldUpdate = true;
_log.fine('Page $page: No local annotation, using server version');
} else if (serverAnnotation.lastModified.isAfter(
localAnnotation.lastModified,
)) {
// Server is newer - overwrite local
shouldUpdate = true;
_log.fine(
'Page $page: Server is newer '
'(server: ${serverAnnotation.lastModified}, '
'local: ${localAnnotation.lastModified})',
);
} else {
_log.fine(
'Page $page: Local is newer or same, keeping local version',
);
}
if (shouldUpdate) {
await _storageService.writeAnnotationsWithMetadata(
sheetUuid,
page,
serverAnnotation.annotationsJson,
serverAnnotation.lastModified,
);
updatedCount++;
}
}
_log.info(
'Sync complete: $updatedCount pages updated from server '
'(${serverAnnotations.length} total on server)',
);
} on ApiException catch (e) {
_log.warning('Failed to sync annotations from server: $e');
} catch (e) {
_log.warning('Unexpected error syncing annotations: $e');
}
}
/// Uploads a single page's annotation to the server.
///
/// Called when annotations are saved (e.g., exiting paint mode).
/// If upload fails (e.g., offline), the annotation is queued for later sync.
Future<bool> uploadAnnotation({
required String sheetUuid,
required int page,
required String annotationsJson,
required DateTime lastModified,
}) async {
try {
_log.info('Uploading annotation for sheet $sheetUuid page $page');
await _apiClient.uploadAnnotation(
sheetUuid: sheetUuid,
page: page,
lastModified: lastModified,
annotationsJson: annotationsJson,
);
_log.info('Upload successful');
return true;
} on ApiException catch (e) {
_log.warning('Failed to upload annotation, queuing for later: $e');
await _queueForLaterUpload(
sheetUuid: sheetUuid,
page: page,
annotationsJson: annotationsJson,
lastModified: lastModified,
);
return false;
} catch (e) {
_log.warning(
'Unexpected error uploading annotation, queuing for later: $e',
);
await _queueForLaterUpload(
sheetUuid: sheetUuid,
page: page,
annotationsJson: annotationsJson,
lastModified: lastModified,
);
return false;
}
}
/// Queues an annotation for later upload when connection is restored.
Future<void> _queueForLaterUpload({
required String sheetUuid,
required int page,
required String annotationsJson,
required DateTime lastModified,
}) async {
await _storageService.writePendingAnnotationUpload(
PendingAnnotationUpload(
sheetUuid: sheetUuid,
page: page,
annotationsJson: annotationsJson,
lastModified: lastModified,
),
);
_log.info('Annotation queued for later upload: $sheetUuid page $page');
}
}
+369
View File
@@ -0,0 +1,369 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:logging/logging.dart';
import 'package:path_provider/path_provider.dart';
import '../models/change.dart';
import '../models/sheet.dart';
/// HTTP client for communicating with the Sheetless API server.
class ApiClient {
final _log = Logger('ApiClient');
final String baseUrl;
String? accessToken; // Short-lived jwt
String? refreshToken; // Long lived binary token
ApiClient({required this.baseUrl, this.refreshToken});
// ---------------------------------------------------------------------------
// Authentication
// ---------------------------------------------------------------------------
Future<void> login(String username, String password) async {
_log.info('Logging in...');
final response = await post("/auth/login", {
'username': username,
'password': password,
}, authenticateWithAccessToken: false);
if (response.statusCode == 200) {
final responseData = jsonDecode(response.body);
accessToken = responseData['access_token'];
refreshToken = responseData['refresh_token'];
_log.info('Login successful');
} else {
throw ApiException(
'Login failed',
statusCode: response.statusCode,
body: response.body,
);
}
}
Future<void> refreshAccessToken() async {
_log.info('Refreshing access token...');
final response = await post("/auth/refresh", {
"refresh_token": refreshToken,
}, authenticateWithAccessToken: false);
if (response.statusCode == 200) {
final responseData = jsonDecode(response.body);
accessToken = responseData['access_token'];
_log.info('Token refresh successful');
} else {
throw ApiException(
'Token refresh failed',
statusCode: response.statusCode,
body: response.body,
);
}
}
Future<void> logout() async {
try {
await post(
"/auth/logout",
{},
authenticateWithAccessToken: true,
); // Ignoring response, logging out in any case
} catch (e) {
_log.fine('Logout failed: $e');
}
accessToken = null;
refreshToken = null;
_log.info('Logged out successfully');
}
// ---------------------------------------------------------------------------
// HTTP Helpers
// ---------------------------------------------------------------------------
Map<String, String> _buildHeaders({
bool authenticateWithAccessToken = false,
bool isBinary = false,
}) {
final headers = {
if (authenticateWithAccessToken) 'Authorization': 'Bearer $accessToken',
if (!isBinary) 'Content-Type': 'application/json',
};
return headers;
}
/// Performs a GET request to the given endpoint.
Future<http.Response> get(
String endpoint, {
bool isBinary = false,
bool authenticateWithAccessToken = true,
bool isRetry = false,
}) async {
final url = Uri.parse('$baseUrl$endpoint');
final response = await http.get(
url,
headers: _buildHeaders(
isBinary: isBinary,
authenticateWithAccessToken: authenticateWithAccessToken,
),
);
// Unauthorized, refresh access token
if (authenticateWithAccessToken && response.statusCode == 401 && !isRetry) {
await refreshAccessToken();
return get(
endpoint,
isBinary: isBinary,
authenticateWithAccessToken: authenticateWithAccessToken,
isRetry: true,
);
}
if (response.statusCode != 200) {
_log.warning(
"GET '$endpoint' failed: ${response.statusCode}\n${response.body}",
);
throw ApiException(
'GET request failed',
statusCode: response.statusCode,
body: response.body,
);
}
return response;
}
/// Performs a POST request with JSON body.
Future<http.Response> post(
String endpoint,
Map<String, dynamic> body, {
bool authenticateWithAccessToken = true,
bool isRetry = false,
}) async {
final url = Uri.parse('$baseUrl$endpoint');
final response = await http.post(
url,
headers: _buildHeaders(
authenticateWithAccessToken: authenticateWithAccessToken,
),
body: jsonEncode(body),
);
// Unauthorized, refresh access token
if (authenticateWithAccessToken && response.statusCode == 401 && !isRetry) {
await refreshAccessToken();
return post(
endpoint,
body,
authenticateWithAccessToken: authenticateWithAccessToken,
isRetry: true,
);
}
if (response.statusCode != 200 && response.statusCode != 201) {
_log.warning(
"POST '$endpoint' failed: ${response.statusCode}\n${response.body}",
);
throw ApiException(
'POST request failed',
statusCode: response.statusCode,
body: response.body,
);
}
return response;
}
/// Performs a POST request with form-encoded body.
Future<http.Response> postFormData(
String endpoint,
String body, {
bool isRetry = false,
}) async {
final url = Uri.parse('$baseUrl$endpoint');
final response = await http.post(
url,
headers: {
'Authorization': 'Bearer $accessToken',
'Content-Type': 'application/x-www-form-urlencoded',
},
body: body,
);
// Unauthorized, refresh access token
if (response.statusCode == 401 && !isRetry) {
await refreshAccessToken();
return postFormData(endpoint, body, isRetry: true);
}
if (response.statusCode != 200 && response.statusCode != 201) {
_log.warning(
"POST Form '$endpoint' failed: ${response.statusCode}\n${response.body}",
);
throw ApiException(
'POST Form request failed',
statusCode: response.statusCode,
body: response.body,
);
}
return response;
}
// ---------------------------------------------------------------------------
// Sheet Operations
// ---------------------------------------------------------------------------
/// Fetches the list of all sheets from the server.
Future<List<Sheet>> fetchSheets() async {
final response = await get('/api/sheets/list');
final data = jsonDecode(response.body);
return (data['sheets'] as List<dynamic>)
.map((sheet) => Sheet.fromJson(sheet as Map<String, dynamic>))
.toList();
}
/// Downloads PDF data for a sheet.
///
/// Returns the raw PDF bytes. For caching, use [getPdfFileCached] instead.
Future<Uint8List> fetchPdfData(String sheetUuid) async {
final response = await get('/api/sheets/get/$sheetUuid', isBinary: true);
_log.info('PDF downloaded for sheet $sheetUuid');
return response.bodyBytes;
}
/// Gets a cached PDF file for a sheet, downloading if necessary.
///
/// On web platforms, use [fetchPdfData] instead as file caching
/// is not supported.
Future<File> getPdfFileCached(String sheetUuid) async {
final cacheDir = await getTemporaryDirectory();
final cachedFile = File('${cacheDir.path}/$sheetUuid.pdf');
if (await cachedFile.exists()) {
_log.info('PDF found in cache: ${cachedFile.path}');
return cachedFile;
}
final pdfData = await fetchPdfData(sheetUuid);
await cachedFile.writeAsBytes(pdfData);
_log.info('PDF cached at: ${cachedFile.path}');
return cachedFile;
}
Future<bool> isPdfFileCached(String sheetUuid) async {
final cacheDir = await getTemporaryDirectory();
final cachedFile = File('${cacheDir.path}/$sheetUuid.pdf');
return await cachedFile.exists();
}
// ---------------------------------------------------------------------------
// Annotation Operations
// ---------------------------------------------------------------------------
/// Fetches all annotations for a sheet from the server.
///
/// Returns a list of [ServerAnnotation] objects containing page number,
/// lastModified timestamp, and the annotations JSON string.
Future<List<ServerAnnotation>> fetchAnnotations(String sheetUuid) async {
final response = await get('/api/sheets/$sheetUuid/annotations');
final data = jsonDecode(response.body) as List<dynamic>;
return data
.map((item) => ServerAnnotation.fromJson(item as Map<String, dynamic>))
.toList();
}
/// Uploads annotations for a specific page of a sheet.
///
/// The [lastModified] should be the current time when the annotation was saved.
Future<void> uploadAnnotation({
required String sheetUuid,
required int page,
required DateTime lastModified,
required String annotationsJson,
}) async {
await post('/api/sheets/$sheetUuid/annotations', {
'page': page,
'lastModified': lastModified.toIso8601String(),
'annotations': annotationsJson,
});
_log.info('Annotation uploaded for sheet $sheetUuid page $page');
}
// ---------------------------------------------------------------------------
// Change Sync Operations
// ---------------------------------------------------------------------------
/// Uploads a batch of changes to the server.
///
/// The server will apply changes based on their [createdAt] timestamps,
/// using the newest change for each field when resolving conflicts.
///
/// Returns the list of change indices that were successfully applied.
/// Throws [ApiException] if the request fails (e.g., offline).
Future<List<int>> uploadChanges(List<Change> changes) async {
if (changes.isEmpty) return [];
final response = await post('/api/changes/sync', {
'changes': changes.map((c) => c.toJson()).toList(),
});
final data = jsonDecode(response.body);
final applied = (data['applied'] as List<dynamic>).cast<int>();
_log.info('Uploaded ${changes.length} changes, ${applied.length} applied');
return applied;
}
/// Checks if the server is reachable.
///
/// Returns true if the server responds, false otherwise.
Future<bool> checkConnection() async {
try {
final response = await get(
"/api/health",
).timeout(const Duration(seconds: 5));
return response.statusCode == 200;
} catch (e) {
_log.fine('Connection check failed: $e');
return false;
}
}
}
/// Represents an annotation from the server.
class ServerAnnotation {
final int page;
final DateTime lastModified;
final String annotationsJson;
ServerAnnotation({
required this.page,
required this.lastModified,
required this.annotationsJson,
});
factory ServerAnnotation.fromJson(Map<String, dynamic> json) {
return ServerAnnotation(
page: json['page'] as int,
lastModified: DateTime.parse(json['lastModified'] as String),
annotationsJson: json['annotations'] as String,
);
}
}
/// Exception thrown when an API request fails.
class ApiException implements Exception {
final String message;
final int statusCode;
final String body;
ApiException(this.message, {required this.statusCode, required this.body});
@override
String toString() => 'ApiException: $message (status: $statusCode)\n$body';
}
+430
View File
@@ -0,0 +1,430 @@
import 'dart:convert';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:hive/hive.dart';
import 'package:sheetless/core/models/change.dart';
import 'package:sheetless/core/models/config.dart';
import 'package:sheetless/core/models/sheet.dart';
/// Keys for secure storage (credentials and tokens).
enum SecureStorageKey { url, refreshToken, username }
/// Data class for storing annotations with metadata.
class StoredAnnotation {
final String annotationsJson;
final DateTime lastModified;
StoredAnnotation({required this.annotationsJson, required this.lastModified});
Map<String, dynamic> toMap() => {
'annotationsJson': annotationsJson,
'lastModified': lastModified.toIso8601String(),
};
factory StoredAnnotation.fromMap(Map<dynamic, dynamic> map) {
return StoredAnnotation(
annotationsJson: map['annotationsJson'] as String,
lastModified: DateTime.parse(map['lastModified'] as String),
);
}
}
/// Service for managing local storage operations.
///
/// Uses [FlutterSecureStorage] for sensitive data (credentials, tokens)
/// and [Hive] for general app data (config, sheet access times, change queue,
/// and PDF annotations).
/// Data class for a pending annotation upload.
class PendingAnnotationUpload {
final String sheetUuid;
final int page;
final String annotationsJson;
final DateTime lastModified;
PendingAnnotationUpload({
required this.sheetUuid,
required this.page,
required this.annotationsJson,
required this.lastModified,
});
Map<String, dynamic> toMap() => {
'sheetUuid': sheetUuid,
'page': page,
'annotationsJson': annotationsJson,
'lastModified': lastModified.toIso8601String(),
};
factory PendingAnnotationUpload.fromMap(Map<dynamic, dynamic> map) {
return PendingAnnotationUpload(
sheetUuid: map['sheetUuid'] as String,
page: map['page'] as int,
annotationsJson: map['annotationsJson'] as String,
lastModified: DateTime.parse(map['lastModified'] as String),
);
}
/// Unique key for deduplication (newer uploads replace older ones).
String get key => '${sheetUuid}_page_$page';
}
/// Service for managing local storage operations.
///
/// Uses [FlutterSecureStorage] for sensitive data (credentials, tokens)
/// and [Hive] for general app data (config, sheet access times, change queue,
/// and PDF annotations).
class StorageService {
// Hive box names
static const String _sheetAccessTimesBox = 'sheetAccessTimes';
static const String _configBox = 'config';
static const String _changeQueueBox = 'changeQueue';
static const String _annotationsBox = 'annotations';
static const String _sheetsBox = 'sheets';
static const String _pendingAnnotationsBox = 'pendingAnnotations';
late final FlutterSecureStorage _secureStorage;
StorageService() {
_secureStorage = FlutterSecureStorage();
}
// ---------------------------------------------------------------------------
// Secure Storage (Credentials & Tokens)
// ---------------------------------------------------------------------------
/// Reads a value from secure storage.
Future<String?> readSecure(SecureStorageKey key) {
return _secureStorage.read(key: key.name);
}
/// Writes a value to secure storage.
///
/// Pass `null` to delete the key.
Future<void> writeSecure(SecureStorageKey key, String? value) {
return _secureStorage.write(key: key.name, value: value);
}
/// Clears all user data except URL and email.
///
/// Called on logout to ensure a clean state for the next user,
/// while preserving server URL and email for convenience.
Future<void> clearAllUserData() async {
// Clear JWT token
await writeSecure(SecureStorageKey.refreshToken, null);
// Clear all Hive boxes
final sheetAccessTimesBox = await Hive.openBox(_sheetAccessTimesBox);
await sheetAccessTimesBox.clear();
final configBox = await Hive.openBox(_configBox);
await configBox.clear();
final changeQueueBox = await Hive.openBox(_changeQueueBox);
await changeQueueBox.clear();
final annotationsBox = await Hive.openBox(_annotationsBox);
await annotationsBox
.clear(); // TODO: this can lead to a loss of important data. Make sure the user is warned
final sheetsBox = await Hive.openBox(_sheetsBox);
await sheetsBox.clear();
final pendingAnnotationsBox = await Hive.openBox(_pendingAnnotationsBox);
await pendingAnnotationsBox.clear();
}
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
/// Reads the app configuration from storage.
Future<Config> readConfig() async {
final box = await Hive.openBox(_configBox);
return Config(
twoPageMode: box.get(Config.keyTwoPageMode) ?? false,
fullscreen: box.get(Config.keyFullscreen) ?? false,
);
}
/// Writes the app configuration to storage.
Future<void> writeConfig(Config config) async {
final box = await Hive.openBox(_configBox);
await box.put(Config.keyTwoPageMode, config.twoPageMode);
await box.put(Config.keyFullscreen, config.fullscreen);
}
// ---------------------------------------------------------------------------
// Sheet Access Times (for sorting by recency)
// ---------------------------------------------------------------------------
/// Reads all sheet access times.
///
/// Returns a map of sheet UUID to last access time.
Future<Map<String, DateTime>> readSheetAccessTimes() async {
final box = await Hive.openBox(_sheetAccessTimesBox);
return box.toMap().map(
(key, value) => MapEntry(key as String, DateTime.parse(value as String)),
);
}
/// Records when a sheet was last accessed.
Future<void> writeSheetAccessTime(String uuid, DateTime datetime) async {
final box = await Hive.openBox(_sheetAccessTimesBox);
await box.put(uuid, datetime.toIso8601String());
}
// ---------------------------------------------------------------------------
// Change Queue (Offline Sync)
// ---------------------------------------------------------------------------
/// Adds a change to the offline queue.
Future<void> writeChange(Change change) async {
final box = await Hive.openBox(_changeQueueBox);
await box.add(change.toMap());
}
/// Reads all pending changes from the queue.
Future<ChangeQueue> readChangeQueue() async {
final box = await Hive.openBox(_changeQueueBox);
final queue = ChangeQueue();
for (final map in box.values) {
queue.addChange(Change.fromMap(map as Map<dynamic, dynamic>));
}
return queue;
}
/// Removes the oldest change from the queue.
///
/// Call this after successfully syncing a change to the server.
Future<void> deleteOldestChange() async {
final box = await Hive.openBox(_changeQueueBox);
if (box.isNotEmpty) {
await box.deleteAt(0);
}
}
// ---------------------------------------------------------------------------
// Annotations (PDF Drawing Persistence)
// ---------------------------------------------------------------------------
/// Generates a storage key for a specific page's annotations.
String _annotationKey(String sheetUuid, int pageNumber) {
return '${sheetUuid}_page_$pageNumber';
}
/// Reads annotations for a specific sheet page.
///
/// Returns the JSON string of annotations, or null if none exist.
Future<String?> readAnnotations(String sheetUuid, int pageNumber) async {
final box = await Hive.openBox(_annotationsBox);
final value = box.get(_annotationKey(sheetUuid, pageNumber));
// Handle legacy format (plain string) and new format (map with metadata)
if (value == null) return null;
if (value is String) return value;
if (value is Map) {
final stored = StoredAnnotation.fromMap(value);
return stored.annotationsJson;
}
return null;
}
/// Writes annotations with a specific lastModified timestamp.
///
/// Used when syncing from server to preserve server's timestamp.
Future<void> writeAnnotationsWithMetadata(
String sheetUuid,
int pageNumber,
String? annotationsJson,
DateTime lastModified,
) async {
final box = await Hive.openBox(_annotationsBox);
final key = _annotationKey(sheetUuid, pageNumber);
if (annotationsJson == null || annotationsJson.isEmpty) {
await box.delete(key);
} else {
final stored = StoredAnnotation(
annotationsJson: annotationsJson,
lastModified: lastModified,
);
await box.put(key, stored.toMap());
}
}
/// Reads all annotations for a sheet (all pages).
///
/// Returns a map of page number to JSON string.
Future<Map<int, String>> readAllAnnotations(String sheetUuid) async {
final box = await Hive.openBox(_annotationsBox);
final prefix = '${sheetUuid}_page_';
final result = <int, String>{};
for (final key in box.keys) {
if (key is String && key.startsWith(prefix)) {
final pageStr = key.substring(prefix.length);
final pageNumber = int.tryParse(pageStr);
if (pageNumber != null) {
final value = box.get(key);
if (value != null) {
// Handle legacy format (plain string) and new format (map)
if (value is String && value.isNotEmpty) {
result[pageNumber] = value;
} else if (value is Map) {
final stored = StoredAnnotation.fromMap(value);
if (stored.annotationsJson.isNotEmpty) {
result[pageNumber] = stored.annotationsJson;
}
}
}
}
}
}
return result;
}
/// Reads all annotations with metadata for a sheet (all pages).
///
/// Returns a map of page number to [StoredAnnotation].
Future<Map<int, StoredAnnotation>> readAllAnnotationsWithMetadata(
String sheetUuid,
) async {
final box = await Hive.openBox(_annotationsBox);
final prefix = '${sheetUuid}_page_';
final result = <int, StoredAnnotation>{};
for (final key in box.keys) {
if (key is String && key.startsWith(prefix)) {
final pageStr = key.substring(prefix.length);
final pageNumber = int.tryParse(pageStr);
if (pageNumber != null) {
final value = box.get(key);
if (value != null) {
StoredAnnotation? stored;
// Handle legacy format (plain string) and new format (map)
if (value is String && value.isNotEmpty) {
stored = StoredAnnotation(
annotationsJson: value,
lastModified: DateTime.fromMillisecondsSinceEpoch(0),
);
} else if (value is Map) {
stored = StoredAnnotation.fromMap(value);
}
if (stored != null && stored.annotationsJson.isNotEmpty) {
result[pageNumber] = stored;
}
}
}
}
}
return result;
}
/// Deletes all annotations for a sheet.
Future<void> deleteAllAnnotations(String sheetUuid) async {
final box = await Hive.openBox(_annotationsBox);
final prefix = '${sheetUuid}_page_';
final keysToDelete = box.keys.where(
(key) => key is String && key.startsWith(prefix),
);
for (final key in keysToDelete.toList()) {
await box.delete(key);
}
}
// ---------------------------------------------------------------------------
// Sheets Cache (Offline Support)
// ---------------------------------------------------------------------------
/// Reads cached sheets from local storage.
///
/// Returns an empty list if no cached sheets exist.
Future<List<Sheet>> readCachedSheets() async {
final box = await Hive.openBox(_sheetsBox);
final sheetsJson = box.get('sheets');
if (sheetsJson == null) return [];
final List<dynamic> sheetsList = jsonDecode(sheetsJson as String);
return sheetsList
.map((json) => Sheet.fromJson(json as Map<String, dynamic>))
.toList();
}
/// Caches the sheets list to local storage.
Future<void> writeCachedSheets(List<Sheet> sheets) async {
final box = await Hive.openBox(_sheetsBox);
final sheetsJson = jsonEncode(sheets.map((s) => s.toJson()).toList());
await box.put('sheets', sheetsJson);
}
// ---------------------------------------------------------------------------
// Pending Annotation Uploads (Offline Support)
// ---------------------------------------------------------------------------
/// Adds or updates a pending annotation upload.
///
/// If an upload for the same sheet/page already exists, it will be replaced
/// with the newer version.
Future<void> writePendingAnnotationUpload(
PendingAnnotationUpload upload,
) async {
final box = await Hive.openBox(_pendingAnnotationsBox);
await box.put(upload.key, upload.toMap());
}
/// Reads all pending annotation uploads.
Future<List<PendingAnnotationUpload>> readPendingAnnotationUploads() async {
final box = await Hive.openBox(_pendingAnnotationsBox);
final uploads = <PendingAnnotationUpload>[];
for (final value in box.values) {
uploads.add(PendingAnnotationUpload.fromMap(value as Map));
}
return uploads;
}
/// Removes a pending annotation upload after successful sync.
Future<void> deletePendingAnnotationUpload(String key) async {
final box = await Hive.openBox(_pendingAnnotationsBox);
await box.delete(key);
}
/// Checks if there are any pending annotation uploads.
Future<bool> hasPendingAnnotationUploads() async {
final box = await Hive.openBox(_pendingAnnotationsBox);
return box.isNotEmpty;
}
// ---------------------------------------------------------------------------
// Change Queue Enhancements
// ---------------------------------------------------------------------------
/// Returns the number of pending changes.
Future<int> getChangeQueueLength() async {
final box = await Hive.openBox(_changeQueueBox);
return box.length;
}
/// Clears all pending changes.
///
/// Use with caution - only call after all changes are synced.
Future<void> clearChangeQueue() async {
final box = await Hive.openBox(_changeQueueBox);
await box.clear();
}
/// Gets all changes as a list (for batch upload).
Future<List<Change>> readChangeList() async {
final box = await Hive.openBox(_changeQueueBox);
return box.values
.map((map) => Change.fromMap(map as Map<dynamic, dynamic>))
.toList();
}
}
+292
View File
@@ -0,0 +1,292 @@
import 'package:logging/logging.dart';
import '../models/change.dart';
import '../models/sheet.dart';
import 'api_client.dart';
import 'storage_service.dart';
/// Result of a sync operation.
class SyncResult {
final List<Sheet> sheets;
final bool isOnline;
final int changesSynced;
final int annotationsSynced;
final int changesUnsynced;
final int annotationsUnsynced;
SyncResult({
required this.sheets,
required this.isOnline,
required this.changesSynced,
required this.annotationsSynced,
required this.changesUnsynced,
required this.annotationsUnsynced,
});
}
/// Service for coordinating offline/online synchronization.
///
/// Handles:
/// - Fetching sheets with offline fallback to cached data
/// - Uploading pending changes when connection is available
/// - Uploading pending annotation uploads
/// - Applying local changes to sheets list
class SyncService {
final _log = Logger('SyncService');
final ApiClient _apiClient;
final StorageService _storageService;
SyncService({
required ApiClient apiClient,
required StorageService storageService,
}) : _apiClient = apiClient,
_storageService = storageService;
/// Performs a full sync operation.
///
/// 1. Checks if online
/// 2. If online: fetches sheets, uploads pending changes, uploads pending annotations
/// 3. If offline: loads cached sheets and applies pending changes locally
///
/// Returns [SyncResult] with the sheets list and sync status.
Future<SyncResult> sync() async {
final isOnline = await _apiClient.checkConnection();
if (isOnline) {
return _syncOnline();
} else {
return _syncOffline();
}
}
/// Online sync: fetch from server, upload pending data.
Future<SyncResult> _syncOnline() async {
_log.info('Online sync starting...');
int changesSynced = 0;
int annotationsSynced = 0;
// 1. Fetch fresh sheets from server
List<Sheet> sheets;
try {
sheets = await _apiClient.fetchSheets();
_log.info('Fetched ${sheets.length} sheets from server');
// Cache the fetched sheets
await _storageService.writeCachedSheets(sheets);
} catch (e) {
_log.warning('Failed to fetch sheets, falling back to cache: $e');
return _syncOffline();
}
// 2. Upload pending changes
changesSynced = await _uploadPendingChanges();
// 3. Upload pending annotations
annotationsSynced = await _uploadPendingAnnotations();
final remainingAnnotations = await _storageService
.readPendingAnnotationUploads();
// 4. Apply any remaining local changes (in case some failed to upload)
final changeQueue = await _storageService.readChangeQueue();
if (changeQueue.isNotEmpty) {
try {
changeQueue.applyToSheets(sheets);
// Update cache with applied changes
await _storageService.writeCachedSheets(sheets);
} catch (e) {
_log.warning('Failed to apply remaining changes: $e');
}
}
_log.info(
'Online sync complete: $changesSynced changes, $annotationsSynced annotations synced',
);
return SyncResult(
sheets: sheets,
isOnline: true,
changesSynced: changesSynced,
annotationsSynced: annotationsSynced,
changesUnsynced: changeQueue.length,
annotationsUnsynced: remainingAnnotations.length,
);
}
/// Offline sync: use cached data with local changes applied.
Future<SyncResult> _syncOffline() async {
_log.info('Offline mode: loading cached data...');
// 1. Load cached sheets
var sheets = await _storageService.readCachedSheets();
if (sheets.isEmpty) {
_log.warning('No cached sheets available in offline mode');
}
// 2. Apply pending changes locally
final changeQueue = await _storageService.readChangeQueue();
if (changeQueue.isNotEmpty) {
_log.info('Applying ${changeQueue.length} pending changes locally');
try {
changeQueue.applyToSheets(sheets);
} catch (e) {
_log.warning('Failed to apply some changes: $e');
}
}
final remainingAnnotations = await _storageService
.readPendingAnnotationUploads();
return SyncResult(
sheets: sheets,
isOnline: false,
changesSynced: 0,
annotationsSynced: 0,
changesUnsynced: changeQueue.length,
annotationsUnsynced: remainingAnnotations.length,
);
}
/// Uploads all pending changes to the server.
///
/// Returns the number of successfully synced changes.
Future<int> _uploadPendingChanges() async {
final changes = await _storageService.readChangeList();
if (changes.isEmpty) return 0;
_log.info('Uploading ${changes.length} pending changes...');
try {
final appliedIndices = await _apiClient.uploadChanges(changes);
// Delete successfully synced changes (in reverse order to maintain indices)
for (int i = appliedIndices.length - 1; i >= 0; i--) {
await _storageService.deleteOldestChange();
}
_log.info('${appliedIndices.length} changes synced successfully');
return appliedIndices.length;
} catch (e) {
_log.warning('Failed to upload changes: $e');
return 0;
}
}
/// Uploads all pending annotation uploads to the server.
///
/// Returns the number of successfully synced annotations.
Future<int> _uploadPendingAnnotations() async {
final pendingUploads = await _storageService.readPendingAnnotationUploads();
if (pendingUploads.isEmpty) return 0;
_log.info('Uploading ${pendingUploads.length} pending annotations...');
int syncedCount = 0;
for (final upload in pendingUploads) {
try {
await _apiClient.uploadAnnotation(
sheetUuid: upload.sheetUuid,
page: upload.page,
lastModified: upload.lastModified,
annotationsJson: upload.annotationsJson,
);
// Delete from pending queue after successful upload
await _storageService.deletePendingAnnotationUpload(upload.key);
syncedCount++;
} catch (e) {
_log.warning(
'Failed to upload annotation for ${upload.sheetUuid} page ${upload.page}: $e',
);
// Continue with other uploads
}
}
_log.info('$syncedCount annotations synced successfully');
return syncedCount;
}
/// Queues a change for sync.
///
/// If online, attempts immediate upload. Otherwise, stores locally.
Future<void> queueChange(Change change) async {
// Always store locally first
await _storageService.writeChange(change);
// Try to upload immediately if online
try {
final isOnline = await _apiClient.checkConnection();
if (isOnline) {
final changes = await _storageService.readChangeList();
final appliedIndices = await _apiClient.uploadChanges(changes);
// Delete synced changes
for (int i = 0; i < appliedIndices.length; i++) {
await _storageService.deleteOldestChange();
}
}
} catch (e) {
_log.fine('Immediate upload failed, change queued for later: $e');
}
}
/// Queues an annotation upload.
///
/// If the upload fails (e.g., offline), it will be stored for later sync.
Future<bool> uploadAnnotationWithFallback({
required String sheetUuid,
required int page,
required String annotationsJson,
required DateTime lastModified,
}) async {
try {
await _apiClient.uploadAnnotation(
sheetUuid: sheetUuid,
page: page,
lastModified: lastModified,
annotationsJson: annotationsJson,
);
return true;
} catch (e) {
_log.fine('Annotation upload failed, queuing for later: $e');
// Store for later upload
await _storageService.writePendingAnnotationUpload(
PendingAnnotationUpload(
sheetUuid: sheetUuid,
page: page,
annotationsJson: annotationsJson,
lastModified: lastModified,
),
);
return false;
}
}
/// Updates the local cache after a sheet edit.
///
/// Call this after applying changes to the sheets list locally.
Future<void> updateCachedSheets(List<Sheet> sheets) async {
await _storageService.writeCachedSheets(sheets);
}
/// Gets the number of pending changes.
Future<int> getPendingChangesCount() async {
return _storageService.getChangeQueueLength();
}
/// Gets the number of pending annotation uploads.
Future<int> getPendingAnnotationsCount() async {
final uploads = await _storageService.readPendingAnnotationUploads();
return uploads.length;
}
/// Checks if there is any pending data to sync.
Future<bool> hasPendingData() async {
final changesCount = await getPendingChangesCount();
final annotationsCount = await getPendingAnnotationsCount();
return changesCount > 0 || annotationsCount > 0;
}
}
-70
View File
@@ -1,70 +0,0 @@
import 'package:flutter/material.dart';
import 'package:sheetless/sheet.dart';
typedef SheetEditedCallback = void Function(String newName, String newComposer);
class EditItemBottomSheet extends StatefulWidget {
final Sheet sheet;
final SheetEditedCallback onSheetEdited;
const EditItemBottomSheet({
super.key,
required this.sheet,
required this.onSheetEdited,
});
@override
State<EditItemBottomSheet> createState() => _EditItemBottomSheetState();
}
class _EditItemBottomSheetState extends State<EditItemBottomSheet> {
late TextEditingController sheetNameController;
late TextEditingController composerNameController;
@override
void initState() {
sheetNameController = TextEditingController(text: widget.sheet.name);
composerNameController = TextEditingController(
text: widget.sheet.composerName,
);
super.initState();
}
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.fromLTRB(
16,
16,
16,
MediaQuery.of(context).viewInsets.bottom + 16,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: sheetNameController,
decoration: InputDecoration(labelText: "Sheet"),
),
TextField(
controller: composerNameController,
decoration: InputDecoration(labelText: "Composer"),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
// TODO: check text fields are not empty
// TODO: save on pressing enter
widget.onSheetEdited(
sheetNameController.text,
composerNameController.text,
);
Navigator.pop(context);
},
child: Text("Save"),
),
],
),
);
}
}
+257
View File
@@ -0,0 +1,257 @@
import 'package:flutter/material.dart';
import 'package:jwt_decoder/jwt_decoder.dart';
import 'package:logging/logging.dart';
import 'package:sheetless/core/services/api_client.dart';
import 'package:sheetless/core/services/storage_service.dart';
import '../home/home_page.dart';
/// Login page for authenticating with the Sheetless server.
class LoginPage extends StatefulWidget {
const LoginPage({super.key});
@override
State<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> {
final _log = Logger('LoginPage');
final _formKey = GlobalKey<FormState>();
final _storageService = StorageService();
final _urlController = TextEditingController(
text: 'https://sheetless.julian-mutter.de',
);
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
String? _errorMessage;
bool _isLoggingIn = false;
@override
void initState() {
super.initState();
_tryAutoLogin();
}
@override
void dispose() {
_urlController.dispose();
_usernameController.dispose();
_passwordController.dispose();
super.dispose();
}
// ---------------------------------------------------------------------------
// Auto-Login
// ---------------------------------------------------------------------------
/// Attempts to auto-login using a stored JWT token.
Future<void> _tryAutoLogin() async {
final refreshToken = await _storageService.readSecure(
SecureStorageKey.refreshToken,
);
if (refreshToken != null) {
// TODO: this assumes the refresh token is always valid
await _navigateToHome();
return;
}
// Restore saved credentials for convenience
await _restoreCredentials();
}
/// Checks if a JWT token is still valid (not expired).
bool _isTokenValid(String jwt) {
try {
return !JwtDecoder.isExpired(jwt);
} on FormatException {
return false;
}
}
/// Restores previously saved URL and username for convenience.
Future<void> _restoreCredentials() async {
final url = await _storageService.readSecure(SecureStorageKey.url);
final username = await _storageService.readSecure(
SecureStorageKey.username,
);
if (url != null) _urlController.text = url;
if (username != null) _usernameController.text = username;
}
// ---------------------------------------------------------------------------
// Login
// ---------------------------------------------------------------------------
/// Handles the login button press.
Future<void> _handleLogin() async {
if (_isLoggingIn) return;
if (!_formKey.currentState!.validate()) return;
setState(() {
_isLoggingIn = true;
_errorMessage = null;
});
try {
final apiClient = ApiClient(baseUrl: _urlController.text);
await apiClient.login(_usernameController.text, _passwordController.text);
// Save credentials for next time
if (apiClient.refreshToken == null) {
_log.warning('Login failed, refreshToken is null');
setState(() {
_errorMessage = 'Login failed.\nRefresh token is null';
});
} else {
await _saveCredentials(apiClient.refreshToken!);
await _navigateToHome();
}
} catch (e) {
_log.warning('Login failed', e);
setState(() {
_errorMessage = 'Login failed.\n$e';
});
} finally {
if (mounted) {
setState(() => _isLoggingIn = false);
}
}
}
/// Saves credentials after successful login.
Future<void> _saveCredentials(String refreshToken) async {
await _storageService.writeSecure(
SecureStorageKey.url,
_urlController.text,
);
await _storageService.writeSecure(
SecureStorageKey.refreshToken,
refreshToken,
);
await _storageService.writeSecure(
SecureStorageKey.username,
_usernameController.text,
);
}
/// Navigates to the home page after successful authentication.
Future<void> _navigateToHome() async {
final config = await _storageService.readConfig();
if (!mounted) return;
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => HomePage(config: config)),
);
}
// ---------------------------------------------------------------------------
// Validation
// ---------------------------------------------------------------------------
String? _validateNotEmpty(String? value) {
if (value == null || value.isEmpty) {
return 'This field is required';
}
return null;
}
// ---------------------------------------------------------------------------
// UI
// ---------------------------------------------------------------------------
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Login')),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildUrlField(),
const SizedBox(height: 16),
_buildUsernameField(),
const SizedBox(height: 16),
_buildPasswordField(),
const SizedBox(height: 24),
_buildLoginButton(),
if (_errorMessage != null) _buildErrorMessage(),
],
),
),
),
),
);
}
Widget _buildUrlField() {
return TextFormField(
controller: _urlController,
validator: _validateNotEmpty,
decoration: const InputDecoration(
labelText: 'Server URL',
prefixIcon: Icon(Icons.dns),
),
keyboardType: TextInputType.url,
textInputAction: TextInputAction.next,
);
}
Widget _buildUsernameField() {
return TextFormField(
controller: _usernameController,
validator: _validateNotEmpty,
decoration: const InputDecoration(
labelText: 'Username',
prefixIcon: Icon(Icons.person),
),
textInputAction: TextInputAction.next,
);
}
Widget _buildPasswordField() {
return TextFormField(
controller: _passwordController,
validator: _validateNotEmpty,
decoration: const InputDecoration(
labelText: 'Password',
prefixIcon: Icon(Icons.lock),
),
obscureText: true,
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => _handleLogin(),
);
}
Widget _buildLoginButton() {
return ElevatedButton(
onPressed: _isLoggingIn ? null : _handleLogin,
child: _isLoggingIn
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Login'),
);
}
Widget _buildErrorMessage() {
return Padding(
padding: const EdgeInsets.only(top: 16.0),
child: Text(
_errorMessage!,
style: const TextStyle(color: Colors.red),
textAlign: TextAlign.center,
),
);
}
}
+280
View File
@@ -0,0 +1,280 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_fullscreen/flutter_fullscreen.dart';
import 'package:logging/logging.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:sheetless/core/models/config.dart';
import 'package:sheetless/core/models/sheet.dart';
import 'package:sheetless/core/services/api_client.dart';
import 'package:sheetless/core/services/storage_service.dart';
import 'package:sheetless/core/services/sync_service.dart';
import '../../app.dart';
import '../auth/login_page.dart';
import '../sheet_viewer/sheet_viewer_page.dart';
import 'widgets/app_drawer.dart';
import 'widgets/sheets_list.dart';
/// Main home page displaying the list of sheet music.
///
/// Features:
/// - Pull-to-refresh sheet list
/// - Shuffle mode for random practice
/// - Navigation to sheet viewer
/// - Logout functionality
class HomePage extends StatefulWidget {
final Config config;
const HomePage({super.key, required this.config});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> with RouteAware {
final _log = Logger('HomePage');
final _storageService = StorageService();
ApiClient? _apiClient;
SyncService? _syncService;
late Future<SyncResult> _syncFuture;
List<Sheet> _sheets = [];
bool _isShuffling = false;
bool _isOnline = true;
String? _appName;
String? _appVersion;
@override
void initState() {
super.initState();
// Exit fullscreen when entering home page
if (FullScreen.isFullScreen) {
FullScreen.setFullScreen(false);
}
// Subscribe to route changes
WidgetsBinding.instance.addPostFrameCallback((_) {
routeObserver.subscribe(this, ModalRoute.of(context)!);
});
_loadAppInfo();
_syncFuture = _loadSheets();
}
@override
void dispose() {
routeObserver.unsubscribe(this);
super.dispose();
}
// ---------------------------------------------------------------------------
// Route Aware (Fullscreen Management)
// ---------------------------------------------------------------------------
@override
void didPush() {
// Exit fullscreen when entering home page
if (FullScreen.isFullScreen) {
FullScreen.setFullScreen(false);
}
super.didPush();
}
@override
void didPopNext() {
// Exit fullscreen when returning to home page
if (FullScreen.isFullScreen) {
FullScreen.setFullScreen(false);
}
super.didPopNext();
}
// ---------------------------------------------------------------------------
// Data Loading
// ---------------------------------------------------------------------------
Future<void> _loadAppInfo() async {
final info = await PackageInfo.fromPlatform();
setState(() {
_appName = info.appName;
_appVersion = info.version;
});
}
Future<SyncResult> _loadSheets() async {
final url = await _storageService.readSecure(SecureStorageKey.url);
final refreshToken = await _storageService.readSecure(
SecureStorageKey.refreshToken,
);
_apiClient = ApiClient(baseUrl: url!, refreshToken: refreshToken);
_syncService = SyncService(
apiClient: _apiClient!,
storageService: _storageService,
);
// Perform sync (fetches sheets, uploads pending changes/annotations)
final result = await _syncService!.sync();
_log.info(
'${result.sheets.length} sheets loaded (online: ${result.isOnline}, '
'changes synced: ${result.changesSynced}, '
'annotations synced: ${result.annotationsSynced})',
);
// Sort and store sheets
_sheets = await _sortSheetsByRecency(result.sheets);
setState(() {
_isOnline = result.isOnline;
});
if (!kIsWeb) {
for (var sheet in _sheets) {
sheet.availableOffline = await _apiClient!.isPdfFileCached(sheet.uuid);
}
}
return result;
}
Future<List<Sheet>> _sortSheetsByRecency(List<Sheet> sheets) async {
final accessTimes = await _storageService.readSheetAccessTimes();
sheets.sort((a, b) {
// Use local access time if available and more recent than server update
var dateA = accessTimes[a.uuid];
var dateB = accessTimes[b.uuid];
if (dateA == null || a.updatedAt.isAfter(dateA)) {
dateA = a.updatedAt;
}
if (dateB == null || b.updatedAt.isAfter(dateB)) {
dateB = b.updatedAt;
}
return dateB.compareTo(dateA); // Most recent first
});
return sheets;
}
Future<void> _refreshSheets() async {
setState(() {
_syncFuture = _loadSheets();
});
}
// ---------------------------------------------------------------------------
// Actions
// ---------------------------------------------------------------------------
void _handleShuffleChanged(bool enabled) async {
if (enabled) {
_sheets.shuffle();
} else {
await _sortSheetsByRecency(_sheets);
}
setState(() => _isShuffling = enabled);
}
Future<void> _handleLogout() async {
await _storageService.clearAllUserData();
await _apiClient!.logout();
if (!mounted) return;
Navigator.of(
context,
).pushReplacement(MaterialPageRoute(builder: (_) => const LoginPage()));
}
void _openSheet(Sheet sheet) {
// Record access time for recency sorting
_storageService.writeSheetAccessTime(sheet.uuid, DateTime.now());
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => SheetViewerPage(
sheet: sheet,
apiClient: _apiClient!,
config: widget.config,
),
),
);
}
// ---------------------------------------------------------------------------
// UI
// ---------------------------------------------------------------------------
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Sheetless'),
if (!_isOnline)
const Padding(
padding: EdgeInsets.only(left: 8),
child: Icon(Icons.cloud_off, color: Colors.orange, size: 20),
),
],
),
),
endDrawer: AppDrawer(
isShuffling: _isShuffling,
onShuffleChanged: _handleShuffleChanged,
onLogout: _handleLogout,
appName: _appName,
appVersion: _appVersion,
syncFuture: _syncFuture,
),
body: RefreshIndicator(onRefresh: _refreshSheets, child: _buildBody()),
);
}
Widget _buildBody() {
return FutureBuilder<SyncResult>(
future: _syncFuture,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
_log.warning('Error loading sheets', snapshot.error);
return _buildError(snapshot.error.toString());
}
if (snapshot.hasData) {
return SheetsList(
sheets: _sheets,
onSheetSelected: _openSheet,
syncService: _syncService!,
);
}
return const Center(child: CircularProgressIndicator());
},
);
}
Widget _buildError(String message) {
return Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
message,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(color: Colors.red),
textAlign: TextAlign.center,
),
),
);
}
}
+114
View File
@@ -0,0 +1,114 @@
import 'package:flutter/material.dart';
import 'package:sheetless/core/services/sync_service.dart';
/// Callback for shuffle state changes.
typedef ShuffleCallback = void Function(bool enabled);
/// Navigation drawer for the home page.
///
/// Provides access to app-level actions like shuffle mode and logout.
class AppDrawer extends StatelessWidget {
final bool isShuffling;
final ShuffleCallback onShuffleChanged;
final VoidCallback onLogout;
final String? appName;
final String? appVersion;
final Future<SyncResult> syncFuture;
const AppDrawer({
super.key,
required this.isShuffling,
required this.onShuffleChanged,
required this.onLogout,
required this.syncFuture,
this.appName,
this.appVersion,
});
@override
Widget build(BuildContext context) {
return Drawer(
child: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 30),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildActions(),
Column(children: [_buildSyncStatus(), _buildAppInfo()]),
],
),
),
),
);
}
Widget _buildActions() {
return Column(
children: [
ListTile(
leading: Icon(Icons.shuffle, color: isShuffling ? Colors.blue : null),
title: const Text('Shuffle'),
onTap: () => onShuffleChanged(!isShuffling),
),
ListTile(
leading: const Icon(Icons.logout),
title: const Text('Logout'),
onTap: onLogout,
),
],
);
}
Widget _buildSyncStatus() {
return Center(
// padding: const EdgeInsets.all(5.0),
child: FutureBuilder<SyncResult>(
future: syncFuture,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Text(
"Error: ${snapshot.error.toString()}",
style: const TextStyle(color: Colors.red),
textAlign: TextAlign.center,
);
}
if (snapshot.hasData) {
final changes = snapshot.data!.changesUnsynced;
final annotations = snapshot.data!.annotationsUnsynced;
if (changes == 0 && annotations == 0) {
return Text(
"All synced!",
style: const TextStyle(color: Colors.black),
textAlign: TextAlign.center,
);
}
return Text(
"$changes changes and $annotations annotations unsynchronized!",
style: const TextStyle(color: Colors.red),
textAlign: TextAlign.center,
);
}
return const Center(child: CircularProgressIndicator());
},
),
);
}
Widget _buildAppInfo() {
final versionText = appName != null && appVersion != null
? '$appName v$appVersion'
: 'Loading...';
return Padding(
padding: const EdgeInsets.all(16.0),
child: Text(versionText, style: const TextStyle(color: Colors.grey)),
);
}
}
@@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import '../../../core/models/sheet.dart';
/// A list tile displaying a single sheet's information.
///
/// Shows the sheet name and composer, with tap and long-press handlers.
class SheetListItem extends StatelessWidget {
final Sheet sheet;
final VoidCallback onTap;
final VoidCallback onLongPress;
const SheetListItem({
super.key,
required this.sheet,
required this.onTap,
required this.onLongPress,
});
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(sheet.name),
subtitle: Text(sheet.composer),
onTap: onTap,
onLongPress: onLongPress,
trailing: Visibility(
visible: !kIsWeb && sheet.availableOffline,
child: Icon(Icons.download_done, color: Colors.green),
),
);
}
}
@@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
/// Search bar for filtering sheets.
///
/// Provides a text input with search icon and clear button.
class SheetSearchBar extends StatelessWidget {
final TextEditingController controller;
final VoidCallback onClear;
const SheetSearchBar({
super.key,
required this.controller,
required this.onClear,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: TextField(
controller: controller,
decoration: InputDecoration(
hintText: 'Search sheets...',
prefixIcon: const Icon(Icons.search),
suffixIcon: controller.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: onClear,
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8.0),
),
),
),
);
}
}
+191
View File
@@ -0,0 +1,191 @@
import 'dart:async';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:sheetless/core/models/change.dart';
import 'package:sheetless/core/models/sheet.dart';
import 'package:sheetless/core/services/sync_service.dart';
import '../../../shared/widgets/edit_sheet_bottom_sheet.dart';
import 'sheet_list_item.dart';
import 'sheet_search_bar.dart';
/// Widget displaying a searchable list of sheets.
///
/// Features:
/// - Debounced search filtering by name and composer
/// - Long-press to edit sheet metadata
/// - Cross-platform scroll support (mouse and touch)
class SheetsList extends StatefulWidget {
final List<Sheet> sheets;
final ValueSetter<Sheet> onSheetSelected;
final SyncService syncService;
const SheetsList({
super.key,
required this.sheets,
required this.onSheetSelected,
required this.syncService,
});
@override
State<SheetsList> createState() => _SheetsListState();
}
class _SheetsListState extends State<SheetsList> {
static const _searchDebounceMs = 500;
final _searchController = TextEditingController();
Timer? _debounceTimer;
late List<Sheet> _filteredSheets;
@override
void initState() {
super.initState();
_filteredSheets = widget.sheets;
_searchController.addListener(_onSearchChanged);
}
@override
void dispose() {
_searchController.removeListener(_onSearchChanged);
_searchController.dispose();
_debounceTimer?.cancel();
super.dispose();
}
// ---------------------------------------------------------------------------
// Search
// ---------------------------------------------------------------------------
void _onSearchChanged() {
_debounceTimer?.cancel();
_debounceTimer = Timer(
const Duration(milliseconds: _searchDebounceMs),
_filterSheets,
);
}
void _filterSheets() {
final query = _searchController.text.toLowerCase().trim();
if (query.isEmpty) {
setState(() => _filteredSheets = widget.sheets);
return;
}
// Split query into terms for multi-word search
final terms = query.split(RegExp(r'\s+'));
setState(() {
_filteredSheets = widget.sheets.where((sheet) {
final name = sheet.name.toLowerCase();
final composer = sheet.composer.toLowerCase();
// Each term must appear in either name or composer
return terms.every(
(term) => name.contains(term) || composer.contains(term),
);
}).toList();
});
}
void _clearSearch() {
_searchController.clear();
setState(() => _filteredSheets = widget.sheets);
}
// ---------------------------------------------------------------------------
// Edit Sheet
// ---------------------------------------------------------------------------
void _openEditSheet(BuildContext context, Sheet sheet) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (_) => EditSheetBottomSheet(
sheet: sheet,
onSave: (newName, newComposer) =>
_handleSheetEdit(sheet, newName, newComposer),
),
);
}
void _handleSheetEdit(Sheet sheet, String newName, String newComposer) {
// Queue changes for server sync (with timestamp for conflict resolution)
if (newName != sheet.name) {
widget.syncService.queueChange(
Change(
type: ChangeType.sheetNameChange,
sheetUuid: sheet.uuid,
value: newName,
),
);
}
if (newComposer != sheet.composer) {
widget.syncService.queueChange(
Change(
type: ChangeType.composerNameChange,
sheetUuid: sheet.uuid,
value: newComposer,
),
);
}
// Update local state
setState(() {
sheet.name = newName;
sheet.composer = newComposer;
});
// Update cached sheets
widget.syncService.updateCachedSheets(widget.sheets);
}
// ---------------------------------------------------------------------------
// Sheet Selection
// ---------------------------------------------------------------------------
void _handleSheetTap(Sheet sheet) {
// Move selected sheet to top of list (most recently accessed)
widget.sheets.remove(sheet);
widget.sheets.insert(0, sheet);
widget.onSheetSelected(sheet);
}
// ---------------------------------------------------------------------------
// UI
// ---------------------------------------------------------------------------
@override
Widget build(BuildContext context) {
return Column(
children: [
SheetSearchBar(controller: _searchController, onClear: _clearSearch),
Expanded(child: _buildList()),
],
);
}
Widget _buildList() {
// Enable both mouse and touch scrolling for web compatibility
return ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(
dragDevices: {PointerDeviceKind.touch, PointerDeviceKind.mouse},
),
child: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
itemCount: _filteredSheets.length,
itemBuilder: (context, index) {
final sheet = _filteredSheets[index];
return SheetListItem(
sheet: sheet,
onTap: () => _handleSheetTap(sheet),
onLongPress: () => _openEditSheet(context, sheet),
);
},
),
);
}
}
@@ -0,0 +1,12 @@
/// Custom drawing library for PDF annotations.
///
/// Provides scalable drawing with normalized coordinates (0-1 range)
/// that work correctly when the canvas size changes.
library;
export 'drawing_board.dart';
export 'drawing_canvas.dart';
export 'drawing_controller.dart';
export 'drawing_line.dart';
export 'drawing_toolbar.dart';
export 'paint_preset.dart';
@@ -0,0 +1,200 @@
import 'package:flutter/material.dart';
import 'drawing_canvas.dart';
import 'drawing_controller.dart';
/// A drawing board that overlays a child widget with a drawing canvas.
///
/// Supports:
/// - Drawing with normalized coordinates that scale correctly
/// - Zooming and panning via InteractiveViewer
/// - Toggle between view-only and drawing modes
class DrawingBoard extends StatefulWidget {
/// The widget to display behind the drawing (e.g., PDF page)
final Widget child;
/// Size of the drawing area (should match child size)
final Size boardSize;
/// Controller for managing drawing state
final DrawingController controller;
/// Whether drawing is enabled (false = view only)
final bool drawingEnabled;
/// Minimum zoom scale
final double minScale;
/// Maximum zoom scale
final double maxScale;
/// Alignment of the board within available space
final Alignment alignment;
const DrawingBoard({
super.key,
required this.child,
required this.boardSize,
required this.controller,
this.drawingEnabled = true,
this.minScale = 1.0,
this.maxScale = 3.0,
this.alignment = Alignment.topCenter,
});
@override
State<DrawingBoard> createState() => _DrawingBoardState();
}
class _DrawingBoardState extends State<DrawingBoard> {
final TransformationController _transformationController =
TransformationController();
/// Tracks whether we're currently in a drawing gesture
bool _isDrawing = false;
/// Tracks the number of active pointers for gesture disambiguation
int _pointerCount = 0;
@override
void dispose() {
_transformationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
// The content that will be transformed (zoomed/panned)
final content = SizedBox(
width: widget.boardSize.width,
height: widget.boardSize.height,
child: Stack(
children: [
// Background child (e.g., PDF)
Positioned.fill(child: widget.child),
// Drawing overlay
Positioned.fill(
child: widget.drawingEnabled
? _buildDrawingLayer()
: DrawingOverlay(
controller: widget.controller,
canvasSize: widget.boardSize,
),
),
],
),
);
if (!widget.drawingEnabled) {
// View-only mode: just show the content (no zoom/pan here,
// let parent handle navigation)
return Align(
alignment: widget.alignment,
child: content,
);
}
// Drawing mode: wrap with InteractiveViewer for zoom/pan
// Use LayoutBuilder to get available size and center content manually
return LayoutBuilder(
builder: (context, constraints) {
// Calculate padding to center the content
final horizontalPadding =
(constraints.maxWidth - widget.boardSize.width) / 2;
final verticalPadding =
(constraints.maxHeight - widget.boardSize.height) / 2;
return InteractiveViewer(
transformationController: _transformationController,
minScale: widget.minScale,
maxScale: widget.maxScale,
boundaryMargin: const EdgeInsets.all(double.infinity),
constrained: false,
panEnabled: !_isDrawing,
scaleEnabled: !_isDrawing,
child: Padding(
padding: EdgeInsets.only(
left: horizontalPadding > 0 ? horizontalPadding : 0,
top: verticalPadding > 0 ? verticalPadding : 0,
),
child: content,
),
);
},
);
}
/// Builds the drawing layer with gesture handling.
///
/// Uses Listener for drawing and distinguishes between:
/// - Single finger: Draw
/// - Two+ fingers: Pan/Zoom (handled by InteractiveViewer)
Widget _buildDrawingLayer() {
return Listener(
onPointerDown: _onPointerDown,
onPointerMove: _onPointerMove,
onPointerUp: _onPointerUp,
onPointerCancel: _onPointerCancel,
behavior: HitTestBehavior.translucent,
child: DrawingOverlay(
controller: widget.controller,
canvasSize: widget.boardSize,
),
);
}
void _onPointerDown(PointerDownEvent event) {
_pointerCount++;
// Only start drawing with single finger touch
if (_pointerCount == 1) {
_isDrawing = true;
final normalized = _toNormalized(event.localPosition);
widget.controller.startLine(normalized);
setState(() {}); // Update to disable pan/scale
} else {
// Multiple fingers: cancel drawing, enable pan/zoom
if (_isDrawing) {
widget.controller.endLine();
_isDrawing = false;
setState(() {});
}
}
}
void _onPointerMove(PointerMoveEvent event) {
if (_isDrawing && _pointerCount == 1) {
final normalized = _toNormalized(event.localPosition);
widget.controller.addPoint(normalized);
}
}
void _onPointerUp(PointerUpEvent event) {
_pointerCount = (_pointerCount - 1).clamp(0, 10);
if (_isDrawing) {
widget.controller.endLine();
_isDrawing = false;
setState(() {}); // Re-enable pan/scale
}
}
void _onPointerCancel(PointerCancelEvent event) {
_pointerCount = (_pointerCount - 1).clamp(0, 10);
if (_isDrawing) {
widget.controller.endLine();
_isDrawing = false;
setState(() {});
}
}
/// Converts local position to normalized coordinates (0-1).
Offset _toNormalized(Offset localPosition) {
return Offset(
(localPosition.dx / widget.boardSize.width).clamp(0.0, 1.0),
(localPosition.dy / widget.boardSize.height).clamp(0.0, 1.0),
);
}
}
@@ -0,0 +1,115 @@
import 'package:flutter/material.dart';
import 'drawing_controller.dart';
import 'drawing_line.dart';
/// Custom painter that renders drawing lines on a canvas.
///
/// Converts normalized coordinates (0-1) to actual canvas coordinates
/// based on the provided canvas size.
class DrawingPainter extends CustomPainter {
final List<DrawingLine> lines;
final DrawingLine? currentLine;
final Size canvasSize;
DrawingPainter({
required this.lines,
required this.currentLine,
required this.canvasSize,
});
@override
void paint(Canvas canvas, Size size) {
// Draw all completed lines
for (final line in lines) {
_drawLine(canvas, line);
}
// Draw the current line being drawn
if (currentLine != null) {
_drawLine(canvas, currentLine!);
}
}
void _drawLine(Canvas canvas, DrawingLine line) {
if (line.points.length < 2) return;
final paint = Paint()
..color = line.color
..strokeWidth = line.strokeWidth * canvasSize.width
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round
..style = PaintingStyle.stroke
..isAntiAlias = true;
// Create path from normalized points
final path = Path();
final firstPoint = _toCanvasPoint(line.points.first);
path.moveTo(firstPoint.dx, firstPoint.dy);
// Use quadratic bezier curves for smooth lines
if (line.points.length == 2) {
final endPoint = _toCanvasPoint(line.points.last);
path.lineTo(endPoint.dx, endPoint.dy);
} else {
for (int i = 1; i < line.points.length - 1; i++) {
final p0 = _toCanvasPoint(line.points[i]);
final p1 = _toCanvasPoint(line.points[i + 1]);
final midPoint = Offset((p0.dx + p1.dx) / 2, (p0.dy + p1.dy) / 2);
path.quadraticBezierTo(p0.dx, p0.dy, midPoint.dx, midPoint.dy);
}
// Draw to the last point
final lastPoint = _toCanvasPoint(line.points.last);
path.lineTo(lastPoint.dx, lastPoint.dy);
}
canvas.drawPath(path, paint);
}
/// Converts a normalized point (0-1) to canvas coordinates.
Offset _toCanvasPoint(Offset normalizedPoint) {
return Offset(
normalizedPoint.dx * canvasSize.width,
normalizedPoint.dy * canvasSize.height,
);
}
@override
bool shouldRepaint(covariant DrawingPainter oldDelegate) {
return lines != oldDelegate.lines ||
currentLine != oldDelegate.currentLine ||
canvasSize != oldDelegate.canvasSize;
}
}
/// A widget that displays drawing lines on a transparent canvas.
///
/// This widget only shows the drawings, it doesn't handle input.
/// Use [DrawingCanvas] or [DrawingBoard] for input handling.
class DrawingOverlay extends StatelessWidget {
final DrawingController controller;
final Size canvasSize;
const DrawingOverlay({
super.key,
required this.controller,
required this.canvasSize,
});
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: controller,
builder: (context, _) {
return CustomPaint(
size: canvasSize,
painter: DrawingPainter(
lines: controller.lines,
currentLine: controller.currentLine,
canvasSize: canvasSize,
),
);
},
);
}
}
@@ -0,0 +1,381 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'drawing_line.dart';
import 'paint_preset.dart';
/// Represents an undoable action.
sealed class DrawingAction {
const DrawingAction();
}
/// Action for adding a line.
class AddLineAction extends DrawingAction {
final DrawingLine line;
const AddLineAction(this.line);
}
/// Action for erasing lines.
class EraseAction extends DrawingAction {
final List<DrawingLine> erasedLines;
const EraseAction(this.erasedLines);
}
/// Controller for managing drawing state with undo/redo support.
///
/// Manages a stack of [DrawingLine] objects and provides methods for
/// drawing, undoing, redoing, and serializing the drawing state.
class DrawingController extends ChangeNotifier {
/// All completed lines in the drawing
final List<DrawingLine> _lines = [];
/// Stack of actions for undo functionality
final List<DrawingAction> _undoStack = [];
/// Stack of actions for redo functionality
final List<DrawingAction> _redoStack = [];
/// The line currently being drawn (null when not drawing)
DrawingLine? _currentLine;
/// Current paint preset being used
PaintPreset _currentPreset = PaintPreset.blackPen;
/// Whether eraser mode is active
bool _isEraserMode = false;
/// Lines erased in the current eraser stroke (for undo as single action)
final List<DrawingLine> _currentErasedLines = [];
/// Maximum number of history steps to keep
final int maxHistorySteps;
/// Whether there are unsaved changes since last load/clear
bool _hasUnsavedChanges = false;
DrawingController({this.maxHistorySteps = 50});
// ---------------------------------------------------------------------------
// Getters
// ---------------------------------------------------------------------------
/// All completed lines (read-only)
List<DrawingLine> get lines => List.unmodifiable(_lines);
/// The line currently being drawn
DrawingLine? get currentLine => _currentLine;
/// Current paint preset
PaintPreset get currentPreset => _currentPreset;
/// Whether eraser mode is active
bool get isEraserMode => _isEraserMode;
/// Whether undo is available
bool get canUndo => _undoStack.isNotEmpty;
/// Whether redo is available
bool get canRedo => _redoStack.isNotEmpty;
/// Whether there are unsaved changes since last load/clear/markSaved
bool get hasUnsavedChanges => _hasUnsavedChanges;
// ---------------------------------------------------------------------------
// Drawing Operations
// ---------------------------------------------------------------------------
/// Starts a new line at the given normalized position.
void startLine(Offset normalizedPoint) {
if (_isEraserMode) {
_currentErasedLines.clear();
_eraseAtPoint(normalizedPoint);
return;
}
_currentLine = DrawingLine(
points: [normalizedPoint],
color: _currentPreset.color,
strokeWidth: _currentPreset.strokeWidth,
);
notifyListeners();
}
/// Adds a point to the current line.
void addPoint(Offset normalizedPoint) {
if (_isEraserMode) {
_eraseAtPoint(normalizedPoint);
return;
}
if (_currentLine == null) return;
// Filter points that are too close to reduce memory usage
if (_currentLine!.isPointTooClose(normalizedPoint)) return;
_currentLine = _currentLine!.addPoint(normalizedPoint);
notifyListeners();
}
/// Completes the current line and adds it to the history.
void endLine() {
if (_isEraserMode) {
// If we erased lines in this stroke, record as single action
if (_currentErasedLines.isNotEmpty) {
_undoStack.add(EraseAction(List.from(_currentErasedLines)));
_redoStack.clear();
_trimHistory();
_currentErasedLines.clear();
_hasUnsavedChanges = true;
notifyListeners(); // Update UI to enable undo button
}
return;
}
if (_currentLine == null) return;
// Only add lines with at least 2 points
if (_currentLine!.points.length >= 2) {
_lines.add(_currentLine!);
_undoStack.add(AddLineAction(_currentLine!));
_redoStack.clear();
_trimHistory();
_hasUnsavedChanges = true;
}
_currentLine = null;
notifyListeners();
}
/// Trims history to maxHistorySteps to prevent memory growth.
void _trimHistory() {
while (_undoStack.length > maxHistorySteps) {
_undoStack.removeAt(0);
}
}
// ---------------------------------------------------------------------------
// Eraser Operations
// ---------------------------------------------------------------------------
/// Eraser hit radius in normalized coordinates
static const double _eraserRadius = 0.015;
/// Checks if a point is near a line and erases it if so.
void _eraseAtPoint(Offset point) {
// Find all lines that intersect with the eraser point
final linesToRemove = <DrawingLine>[];
for (final line in _lines) {
if (_lineIntersectsPoint(
line, point, _eraserRadius + line.strokeWidth / 2)) {
linesToRemove.add(line);
}
}
// Remove intersecting lines and track them for undo
for (final line in linesToRemove) {
_lines.remove(line);
_currentErasedLines.add(line);
}
if (linesToRemove.isNotEmpty) {
notifyListeners();
}
}
/// Checks if a line intersects with a circular point.
bool _lineIntersectsPoint(DrawingLine line, Offset point, double radius) {
// Check if any point is within radius
for (final linePoint in line.points) {
if ((linePoint - point).distance <= radius) {
return true;
}
}
// Check line segments
for (int i = 0; i < line.points.length - 1; i++) {
if (_pointToSegmentDistance(point, line.points[i], line.points[i + 1]) <=
radius) {
return true;
}
}
return false;
}
/// Calculates the distance from a point to a line segment.
double _pointToSegmentDistance(Offset point, Offset segStart, Offset segEnd) {
final dx = segEnd.dx - segStart.dx;
final dy = segEnd.dy - segStart.dy;
final lengthSquared = dx * dx + dy * dy;
if (lengthSquared == 0) {
// Segment is a point
return (point - segStart).distance;
}
// Project point onto the line, clamping to segment
var t = ((point.dx - segStart.dx) * dx + (point.dy - segStart.dy) * dy) /
lengthSquared;
t = t.clamp(0.0, 1.0);
final projection = Offset(
segStart.dx + t * dx,
segStart.dy + t * dy,
);
return (point - projection).distance;
}
// ---------------------------------------------------------------------------
// Eraser Mode
// ---------------------------------------------------------------------------
/// Toggles eraser mode.
void toggleEraserMode() {
_isEraserMode = !_isEraserMode;
notifyListeners();
}
/// Sets eraser mode.
void setEraserMode(bool enabled) {
if (_isEraserMode != enabled) {
_isEraserMode = enabled;
notifyListeners();
}
}
// ---------------------------------------------------------------------------
// Undo/Redo
// ---------------------------------------------------------------------------
/// Undoes the last action.
void undo() {
if (!canUndo) return;
final action = _undoStack.removeLast();
switch (action) {
case AddLineAction(:final line):
// Remove the line that was added
_lines.remove(line);
_redoStack.add(action);
case EraseAction(:final erasedLines):
// Restore the lines that were erased
_lines.addAll(erasedLines);
_redoStack.add(action);
}
_hasUnsavedChanges = true;
notifyListeners();
}
/// Redoes the last undone action.
void redo() {
if (!canRedo) return;
final action = _redoStack.removeLast();
switch (action) {
case AddLineAction(:final line):
// Re-add the line
_lines.add(line);
_undoStack.add(action);
case EraseAction(:final erasedLines):
// Re-erase the lines
for (final line in erasedLines) {
_lines.remove(line);
}
_undoStack.add(action);
}
_hasUnsavedChanges = true;
notifyListeners();
}
/// Clears all lines from the canvas.
void clear() {
_lines.clear();
_undoStack.clear();
_redoStack.clear();
_currentLine = null;
_currentErasedLines.clear();
_hasUnsavedChanges = false;
notifyListeners();
}
// ---------------------------------------------------------------------------
// Paint Preset
// ---------------------------------------------------------------------------
/// Sets the current paint preset and disables eraser mode.
void setPreset(PaintPreset preset) {
_currentPreset = preset;
_isEraserMode = false;
notifyListeners();
}
// ---------------------------------------------------------------------------
// Serialization
// ---------------------------------------------------------------------------
/// Exports all lines to a JSON-serializable list.
List<Map<String, dynamic>> toJsonList() {
return _lines.map((line) => line.toJson()).toList();
}
/// Exports all lines to a JSON string.
String toJsonString() {
return jsonEncode(toJsonList());
}
/// Imports lines from a JSON-serializable list.
void fromJsonList(List<Map<String, dynamic>> jsonList) {
_lines.clear();
_undoStack.clear();
_redoStack.clear();
_currentLine = null;
_currentErasedLines.clear();
_hasUnsavedChanges = false;
for (final json in jsonList) {
_lines.add(DrawingLine.fromJson(json));
}
notifyListeners();
}
/// Imports lines from a JSON string.
void fromJsonString(String jsonString) {
if (jsonString.isEmpty || jsonString == '[]') {
clear();
return;
}
final decoded = jsonDecode(jsonString) as List;
if (decoded.isEmpty) {
clear();
return;
}
fromJsonList(decoded.cast<Map<String, dynamic>>());
}
/// Adds existing lines without clearing (for merging annotations).
void addLines(List<DrawingLine> newLines) {
_lines.addAll(newLines);
_trimHistory();
notifyListeners();
}
/// Marks the current state as saved (resets unsaved changes flag).
void markSaved() {
_hasUnsavedChanges = false;
}
@override
void dispose() {
_lines.clear();
_undoStack.clear();
_redoStack.clear();
super.dispose();
}
}
@@ -0,0 +1,99 @@
import 'dart:ui';
/// Represents a single stroke/line drawn on the canvas.
///
/// Points are stored in normalized coordinates (0.0 to 1.0) where:
/// - (0, 0) is the top-left corner of the drawing area
/// - (1, 1) is the bottom-right corner of the drawing area
///
/// This allows drawings to scale correctly when the canvas size changes.
class DrawingLine {
/// The minimal squared distance between to points which are normalized so that this point is allowed to be added to the line
static const minNormalizedPointDistanceSquared = 0.001 * 0.001;
/// Points in normalized coordinates (0.0 to 1.0)
final List<Offset> points;
/// Color of the line (stored as ARGB integer for JSON serialization)
final Color color;
/// Stroke width in normalized units (relative to canvas width)
/// A value of 0.01 means the stroke is 1% of the canvas width
final double strokeWidth;
const DrawingLine({
required this.points,
required this.color,
required this.strokeWidth,
});
/// Creates a DrawingLine from JSON data.
factory DrawingLine.fromJson(Map<String, dynamic> json) {
final pointsList = (json['points'] as List)
.map(
(p) => Offset((p['x'] as num).toDouble(), (p['y'] as num).toDouble()),
)
.toList();
return DrawingLine(
points: pointsList,
color: Color(json['color'] as int),
strokeWidth: (json['strokeWidth'] as num).toDouble(),
);
}
/// Converts this line to a JSON-serializable map.
Map<String, dynamic> toJson() {
return {
'points': points.map((p) => {'x': p.dx, 'y': p.dy}).toList(),
'color': color.toARGB32(),
'strokeWidth': strokeWidth,
};
}
/// Creates a copy of this line with an additional point.
DrawingLine addPoint(Offset normalizedPoint) {
return DrawingLine(
points: [...points, normalizedPoint],
color: color,
strokeWidth: strokeWidth,
);
}
bool isPointTooClose(Offset nextNormalizedPoint) {
if (points.isEmpty) {
return false;
}
return (points.last - nextNormalizedPoint).distanceSquared <
minNormalizedPointDistanceSquared;
}
/// Creates a copy with updated points.
DrawingLine copyWith({
List<Offset>? points,
Color? color,
double? strokeWidth,
}) {
return DrawingLine(
points: points ?? this.points,
color: color ?? this.color,
strokeWidth: strokeWidth ?? this.strokeWidth,
);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is! DrawingLine) return false;
if (points.length != other.points.length) return false;
if (color != other.color) return false;
if (strokeWidth != other.strokeWidth) return false;
for (int i = 0; i < points.length; i++) {
if (points[i] != other.points[i]) return false;
}
return true;
}
@override
int get hashCode => Object.hash(Object.hashAll(points), color, strokeWidth);
}
@@ -0,0 +1,236 @@
import 'package:flutter/material.dart';
import 'drawing_controller.dart';
import 'paint_preset.dart';
/// A floating toolbar for drawing controls.
///
/// Provides quick access to:
/// - Paint presets (pens and markers)
/// - Undo/Redo buttons
class DrawingToolbar extends StatelessWidget {
final DrawingController controller;
final VoidCallback? onClose;
const DrawingToolbar({
super.key,
required this.controller,
this.onClose,
});
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: controller,
builder: (context, _) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.2),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Paint presets
...PaintPreset.quickAccess.map((preset) => _buildPresetButton(
context,
preset,
isSelected: !controller.isEraserMode &&
controller.currentPreset == preset,
)),
const SizedBox(width: 8),
_buildDivider(context),
const SizedBox(width: 8),
// Eraser button
_buildEraserButton(
context,
isSelected: controller.isEraserMode,
),
const SizedBox(width: 8),
_buildDivider(context),
const SizedBox(width: 8),
// Undo button
_buildActionButton(
context,
icon: Icons.undo,
onPressed: controller.canUndo ? controller.undo : null,
tooltip: 'Undo',
),
// Redo button
_buildActionButton(
context,
icon: Icons.redo,
onPressed: controller.canRedo ? controller.redo : null,
tooltip: 'Redo',
),
if (onClose != null) ...[
const SizedBox(width: 8),
_buildDivider(context),
const SizedBox(width: 8),
_buildActionButton(
context,
icon: Icons.close,
onPressed: onClose,
tooltip: 'Exit Drawing Mode',
),
],
],
),
);
},
);
}
Widget _buildPresetButton(
BuildContext context,
PaintPreset preset, {
required bool isSelected,
}) {
final isMarker = preset.strokeWidth > 0.01;
final colorScheme = Theme.of(context).colorScheme;
return Tooltip(
message: preset.name,
child: InkWell(
onTap: () => controller.setPreset(preset),
borderRadius: BorderRadius.circular(20),
child: Container(
width: 36,
height: 36,
margin: const EdgeInsets.symmetric(horizontal: 2),
decoration: BoxDecoration(
shape: BoxShape.circle,
border: isSelected
? Border.all(color: colorScheme.primary, width: 2)
: null,
),
child: Center(
child: Container(
width: isMarker ? 24 : 18,
height: isMarker ? 12 : 18,
decoration: BoxDecoration(
color: preset.color,
borderRadius: isMarker
? BorderRadius.circular(2)
: BorderRadius.circular(9),
border: preset.color.a < 1
? Border.all(color: Colors.grey.shade400, width: 0.5)
: null,
),
),
),
),
),
);
}
Widget _buildEraserButton(
BuildContext context, {
required bool isSelected,
}) {
final colorScheme = Theme.of(context).colorScheme;
return Tooltip(
message: 'Eraser',
child: InkWell(
onTap: () => controller.setEraserMode(true),
borderRadius: BorderRadius.circular(20),
child: Container(
width: 36,
height: 36,
margin: const EdgeInsets.symmetric(horizontal: 2),
decoration: BoxDecoration(
shape: BoxShape.circle,
border: isSelected
? Border.all(color: colorScheme.primary, width: 2)
: null,
),
child: Icon(
Icons.auto_fix_high,
size: 20,
color: colorScheme.onSurface,
),
),
),
);
}
Widget _buildActionButton(
BuildContext context, {
required IconData icon,
required VoidCallback? onPressed,
required String tooltip,
}) {
final colorScheme = Theme.of(context).colorScheme;
return Tooltip(
message: tooltip,
child: InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(20),
child: Container(
width: 36,
height: 36,
margin: const EdgeInsets.symmetric(horizontal: 2),
child: Icon(
icon,
size: 20,
color: onPressed != null
? colorScheme.onSurface
: colorScheme.onSurface.withValues(alpha: 0.3),
),
),
),
);
}
Widget _buildDivider(BuildContext context) {
return Container(
width: 1,
height: 24,
color: Theme.of(context).dividerColor,
);
}
}
/// A compact floating action button to toggle paint mode.
class DrawingModeButton extends StatelessWidget {
final bool isActive;
final VoidCallback onPressed;
const DrawingModeButton({
super.key,
required this.isActive,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
return FloatingActionButton.small(
onPressed: onPressed,
backgroundColor: isActive
? Theme.of(context).colorScheme.primaryContainer
: Theme.of(context).colorScheme.surface,
child: Icon(
isActive ? Icons.brush : Icons.brush_outlined,
color: isActive
? Theme.of(context).colorScheme.onPrimaryContainer
: Theme.of(context).colorScheme.onSurface,
),
);
}
}
@@ -0,0 +1,111 @@
import 'package:flutter/material.dart';
/// Predefined paint configurations for common annotation styles.
///
/// Each preset defines a color and stroke width for drawing.
/// Stroke width is normalized (relative to canvas width).
class PaintPreset {
/// Display name for the preset
final String name;
/// Color of the paint (including opacity)
final Color color;
/// Stroke width in normalized units (relative to canvas width)
/// A value of 0.005 means the stroke is 0.5% of the canvas width
final double strokeWidth;
/// Icon to display for this preset
final IconData icon;
const PaintPreset({
required this.name,
required this.color,
required this.strokeWidth,
required this.icon,
});
// ---------------------------------------------------------------------------
// Default Presets
// ---------------------------------------------------------------------------
/// Black pen for writing/notes
static const blackPen = PaintPreset(
name: 'Black Pen',
color: Colors.black,
strokeWidth: 0.003, // Thin line for writing
icon: Icons.edit,
);
/// Red pen for corrections/markings
static const redPen = PaintPreset(
name: 'Red Pen',
color: Colors.red,
strokeWidth: 0.003,
icon: Icons.edit,
);
/// Blue pen for annotations
static const bluePen = PaintPreset(
name: 'Blue Pen',
color: Colors.blue,
strokeWidth: 0.003,
icon: Icons.edit,
);
/// Yellow highlighter (semi-transparent, thicker)
static const yellowMarker = PaintPreset(
name: 'Yellow Marker',
color: Color(0x80FFEB3B), // Yellow with 50% opacity
strokeWidth: 0.02, // Thicker for highlighting
icon: Icons.highlight,
);
/// Green highlighter
static const greenMarker = PaintPreset(
name: 'Green Marker',
color: Color(0x804CAF50), // Green with 50% opacity
strokeWidth: 0.018,
icon: Icons.highlight,
);
/// Pink highlighter
static const pinkMarker = PaintPreset(
name: 'Pink Marker',
color: Color(0x80E91E63), // Pink with 50% opacity
strokeWidth: 0.015,
icon: Icons.highlight,
);
/// All available default presets
static const List<PaintPreset> defaults = [
blackPen,
redPen,
bluePen,
yellowMarker,
greenMarker,
pinkMarker,
];
/// Quick access presets (shown in main toolbar)
static const List<PaintPreset> quickAccess = [
blackPen,
redPen,
bluePen,
yellowMarker,
greenMarker,
pinkMarker,
];
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is PaintPreset &&
other.name == name &&
other.color == color &&
other.strokeWidth == strokeWidth;
}
@override
int get hashCode => Object.hash(name, color, strokeWidth);
}
@@ -0,0 +1,435 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_fullscreen/flutter_fullscreen.dart';
import 'package:logging/logging.dart';
import 'package:pdfrx/pdfrx.dart';
import 'package:sheetless/core/models/config.dart';
import 'package:sheetless/core/models/sheet.dart';
import 'package:sheetless/core/services/annotation_sync_service.dart';
import 'package:sheetless/core/services/api_client.dart';
import 'package:sheetless/core/services/storage_service.dart';
import '../../shared/input/pedal_shortcuts.dart';
import 'drawing/drawing.dart';
import 'widgets/pdf_page_display.dart';
import 'widgets/touch_navigation_layer.dart';
/// Page for viewing and annotating PDF sheet music.
class SheetViewerPage extends StatefulWidget {
final Sheet sheet;
final ApiClient apiClient;
final Config config;
const SheetViewerPage({
super.key,
required this.sheet,
required this.apiClient,
required this.config,
});
@override
State<SheetViewerPage> createState() => _SheetViewerPageState();
}
class _SheetViewerPageState extends State<SheetViewerPage>
with FullScreenListener {
final _log = Logger('SheetViewerPage');
final _storageService = StorageService();
late final AnnotationSyncService _syncService;
PdfDocument? _document;
late Future<bool> _documentLoaded;
late Future<void> _annotationsLoaded;
int _currentPage = 1;
int _totalPages = 1;
bool _isPaintMode = false;
/// Drawing controller for the current left page
late DrawingController _leftDrawingController;
/// Drawing controller for the right page (two-page mode)
late DrawingController _rightDrawingController;
@override
void initState() {
super.initState();
// Initialize sync service
_syncService = AnnotationSyncService(
apiClient: widget.apiClient,
storageService: _storageService,
);
// Initialize drawing controllers
_leftDrawingController = DrawingController(maxHistorySteps: 50);
_rightDrawingController = DrawingController(maxHistorySteps: 50);
FullScreen.addListener(this);
if (FullScreen.isFullScreen != widget.config.fullscreen) {
FullScreen.setFullScreen(widget.config.fullscreen);
}
_documentLoaded = _loadPdf();
_syncAnnotations();
}
void _syncAnnotations() {
setState(() {
_annotationsLoaded = _syncService.syncAnnotationsFromServer(
widget.sheet.uuid,
);
});
}
@override
void dispose() {
// Make sure annotations are saved before exiting
if (_isPaintMode) {
_saveCurrentAnnotations();
}
_leftDrawingController.dispose();
_rightDrawingController.dispose();
FullScreen.removeListener(this);
_document?.dispose();
super.dispose();
}
// ---------------------------------------------------------------------------
// PDF Loading
// ---------------------------------------------------------------------------
Future<bool> _loadPdf() async {
if (kIsWeb) {
// Web: load directly into memory
final data = await widget.apiClient.fetchPdfData(widget.sheet.uuid);
_document = await PdfDocument.openData(data);
} else {
// Native: use file cache
final file = await widget.apiClient.getPdfFileCached(widget.sheet.uuid);
_log.fine("Found file, opening...");
_document = await PdfDocument.openFile(file.path);
widget.sheet.availableOffline = true;
}
_log.fine("Pdf loaded");
setState(() {
_totalPages = _document!.pages.length;
});
// Load annotations for current page(s)
await _loadAnnotationsForCurrentPages();
_log.fine("Annotations loaded");
return true;
}
// ---------------------------------------------------------------------------
// Annotation Persistence
// ---------------------------------------------------------------------------
/// Loads annotations for the current page(s) from storage.
Future<void> _loadAnnotationsForCurrentPages() async {
// Load left page annotations
final leftJson = await _storageService.readAnnotations(
widget.sheet.uuid,
_currentPage,
);
if (leftJson != null && leftJson.isNotEmpty) {
_leftDrawingController.fromJsonString(leftJson);
} else {
_leftDrawingController.clear();
}
// Load right page annotations (two-page mode)
if (widget.config.twoPageMode && _currentPage < _totalPages) {
final rightJson = await _storageService.readAnnotations(
widget.sheet.uuid,
_currentPage + 1,
);
if (rightJson != null && rightJson.isNotEmpty) {
_rightDrawingController.fromJsonString(rightJson);
} else {
_rightDrawingController.clear();
}
}
}
/// Saves the current page(s) annotations to storage and uploads to server.
///
/// Only saves if there are actual changes to avoid unnecessary writes/uploads.
Future<void> _saveCurrentAnnotations() async {
final now = DateTime.now();
// Save left page only if changed
if (_leftDrawingController.hasUnsavedChanges) {
final leftJson = _leftDrawingController.toJsonString();
final leftHasContent = leftJson.isNotEmpty && leftJson != '[]';
await _storageService.writeAnnotationsWithMetadata(
widget.sheet.uuid,
_currentPage,
leftHasContent ? leftJson : null,
now,
);
// Upload left page to server
_syncService.uploadAnnotation(
sheetUuid: widget.sheet.uuid,
page: _currentPage,
annotationsJson: leftJson,
lastModified: now,
);
_leftDrawingController.markSaved();
}
// Save right page (two-page mode) only if changed
if (widget.config.twoPageMode &&
_currentPage < _totalPages &&
_rightDrawingController.hasUnsavedChanges) {
final rightJson = _rightDrawingController.toJsonString();
final rightHasContent = rightJson.isNotEmpty && rightJson != '[]';
await _storageService.writeAnnotationsWithMetadata(
widget.sheet.uuid,
_currentPage + 1,
rightHasContent ? rightJson : null,
now,
);
// Upload right page to server
_syncService.uploadAnnotation(
sheetUuid: widget.sheet.uuid,
page: _currentPage + 1,
annotationsJson: rightJson,
lastModified: now,
);
_rightDrawingController.markSaved();
}
}
// ---------------------------------------------------------------------------
// Fullscreen
// ---------------------------------------------------------------------------
@override
void onFullScreenChanged(bool enabled, SystemUiMode? systemUiMode) {
setState(() {
widget.config.fullscreen = enabled;
_storageService.writeConfig(widget.config);
});
}
void _toggleFullscreen() {
FullScreen.setFullScreen(!FullScreen.isFullScreen);
}
// ---------------------------------------------------------------------------
// Navigation
// ---------------------------------------------------------------------------
Future<void> _turnPage(int delta) async {
// Save current annotations before turning
await _saveCurrentAnnotations();
// Calculate new page
final newPage = (_currentPage + delta).clamp(1, _totalPages);
// Load annotations for new page(s) BEFORE updating state
_currentPage = newPage;
await _loadAnnotationsForCurrentPages();
// Now update UI
setState(() {});
}
// ---------------------------------------------------------------------------
// Mode Switching
// ---------------------------------------------------------------------------
void _togglePaintMode() {
if (widget.config.twoPageMode) {
_showSnackBar('Paint mode is only available in single page mode');
return;
}
if (_isPaintMode) {
// Exiting paint mode - save annotations
_saveCurrentAnnotations();
}
setState(() => _isPaintMode = !_isPaintMode);
}
void _toggleTwoPageMode() {
if (_isPaintMode) {
_showSnackBar('Cannot enter two-page mode while painting');
return;
}
setState(() {
widget.config.twoPageMode = !widget.config.twoPageMode;
_storageService.writeConfig(widget.config);
});
// Reload annotations for new mode
_loadAnnotationsForCurrentPages();
}
// ---------------------------------------------------------------------------
// UI
// ---------------------------------------------------------------------------
@override
Widget build(BuildContext context) {
return PedalShortcuts(
onPageForward: () => _turnPage(1),
onPageBackward: () => _turnPage(-1),
child: Scaffold(
appBar: _buildAppBar(),
body: _buildBody(),
floatingActionButton: _isPaintMode ? _buildDrawingToolbar() : null,
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
),
);
}
PreferredSizeWidget? _buildAppBar() {
// Hide app bar in fullscreen when document is loaded
if (widget.config.fullscreen && _document != null) {
return null;
}
return AppBar(
title: Text(widget.sheet.name),
actions: [
IconButton(
icon: Icon(
widget.config.fullscreen ? Icons.fullscreen_exit : Icons.fullscreen,
),
tooltip: widget.config.fullscreen
? 'Exit Fullscreen'
: 'Enter Fullscreen',
onPressed: _toggleFullscreen,
),
FutureBuilder<void>(
future: _annotationsLoaded,
builder: (context, snapshot) {
if (snapshot.hasError) {
_log.warning('Error loading annotations', snapshot.error);
return IconButton(
onPressed: _syncAnnotations,
icon: Icon(Icons.error, color: Colors.red),
);
}
if (snapshot.connectionState == ConnectionState.done) {
_loadAnnotationsForCurrentPages();
return IconButton(
onPressed: _syncAnnotations,
icon: Icon(Icons.refresh),
);
}
return const CircularProgressIndicator();
},
),
IconButton(
icon: Icon(_isPaintMode ? Icons.brush : Icons.brush_outlined),
tooltip: _isPaintMode ? 'Exit Paint Mode' : 'Enter Paint Mode',
onPressed: _togglePaintMode,
),
IconButton(
icon: Icon(
widget.config.twoPageMode ? Icons.filter_1 : Icons.filter_2,
),
tooltip: widget.config.twoPageMode
? 'Single Page Mode'
: 'Two Page Mode',
onPressed: _toggleTwoPageMode,
),
],
);
}
Widget _buildBody() {
return FutureBuilder<bool>(
future: _documentLoaded,
builder: (context, snapshot) {
if (snapshot.hasError) {
_log.warning('Error loading PDF', snapshot.error);
return _buildError(snapshot.error.toString());
}
if (snapshot.hasData && _document != null) {
return _buildPdfViewer();
}
return const Center(child: CircularProgressIndicator());
},
);
}
Widget _buildPdfViewer() {
final pageDisplay = PdfPageDisplay(
document: _document!,
numPages: _totalPages,
currentPageNumber: _currentPage,
config: widget.config,
leftDrawingController: _leftDrawingController,
rightDrawingController: widget.config.twoPageMode
? _rightDrawingController
: null,
drawingEnabled: _isPaintMode,
);
// When in paint mode, show the page display directly (DrawingBoard handles zoom/pan)
if (_isPaintMode) {
return pageDisplay;
}
// When not in paint mode, wrap with touch navigation
return TouchNavigationLayer(
pageDisplay: pageDisplay,
config: widget.config,
onToggleFullscreen: _toggleFullscreen,
onExit: () => Navigator.pop(context),
onPageTurn: _turnPage,
);
}
Widget _buildDrawingToolbar() {
return SafeArea(
child: Padding(
padding: const EdgeInsets.only(bottom: 16),
child: DrawingToolbar(
controller: _leftDrawingController,
onClose: _togglePaintMode,
),
),
);
}
Widget _buildError(String message) {
return Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
message,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(color: Colors.red),
textAlign: TextAlign.center,
),
),
);
}
void _showSnackBar(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), duration: const Duration(seconds: 2)),
);
}
}
@@ -0,0 +1,197 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:pdfrx/pdfrx.dart';
import '../../../core/models/config.dart';
import '../drawing/drawing.dart';
/// Displays PDF pages with optional two-page mode and drawing overlay.
class PdfPageDisplay extends StatelessWidget {
final PdfDocument document;
final int numPages;
final int currentPageNumber;
final Config config;
/// Controller for the left/main page drawing
final DrawingController? leftDrawingController;
/// Controller for the right page drawing (two-page mode only)
final DrawingController? rightDrawingController;
/// Whether drawing is enabled
final bool drawingEnabled;
const PdfPageDisplay({
super.key,
required this.document,
required this.numPages,
required this.currentPageNumber,
required this.config,
this.leftDrawingController,
this.rightDrawingController,
this.drawingEnabled = false,
});
/// Whether two-page mode is active and we have enough pages.
bool get _showTwoPages => config.twoPageMode && numPages >= 2;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final maxSize = Size(constraints.maxWidth, constraints.maxHeight);
final (leftSize, rightSize) = calculateScaledPageSizes(maxSize);
if (_showTwoPages) {
// Two-page mode: pages touch each other and are centered together
return Center(
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
_buildPage(
pageNumber: currentPageNumber,
pageSize: leftSize,
controller: leftDrawingController,
),
// Only show right page if there is one
if (currentPageNumber < numPages)
_buildPage(
pageNumber: currentPageNumber + 1,
pageSize: rightSize!,
controller: rightDrawingController,
)
else
// Empty space to keep left page position consistent on last page
SizedBox(width: rightSize!.width, height: rightSize.height),
],
),
);
}
// Single page mode
return Center(
child: _buildPage(
pageNumber: currentPageNumber,
pageSize: leftSize,
controller: leftDrawingController,
),
);
},
);
}
Widget _buildPage({
required int pageNumber,
required Size pageSize,
required DrawingController? controller,
}) {
final pdfPage = SizedBox(
width: pageSize.width,
height: pageSize.height,
child: Stack(
children: [
PdfPageView(
key: ValueKey(pageNumber),
document: document,
pageNumber: pageNumber,
maximumDpi: 300,
alignment: Alignment.center,
),
_buildPageIndicator(pageNumber, pageSize),
],
),
);
// If no controller, just show the PDF
if (controller == null) {
return pdfPage;
}
// Wrap with DrawingBoard
return DrawingBoard(
boardSize: pageSize,
controller: controller,
drawingEnabled: drawingEnabled,
minScale: 1.0,
maxScale: 3.0,
alignment: Alignment.center,
child: pdfPage,
);
}
Widget _buildPageIndicator(int pageNumber, Size pageSize) {
return Positioned(
bottom: 5,
left: 0,
right: 0,
child: Center(
child: Text(
'$pageNumber / $numPages',
style: const TextStyle(
fontSize: 12,
color: Colors.black54,
),
),
),
);
}
// ---------------------------------------------------------------------------
// Page Size Calculations
// ---------------------------------------------------------------------------
/// Calculates scaled page sizes for the current view.
///
/// Returns a tuple of (leftPageSize, rightPageSize).
/// rightPageSize is null when not in two-page mode.
(Size, Size?) calculateScaledPageSizes(Size parentSize) {
if (config.twoPageMode) {
return _calculateTwoPageSizes(parentSize);
}
return (_calculateSinglePageSize(parentSize), null);
}
(Size, Size?) _calculateTwoPageSizes(Size parentSize) {
final leftSize = _getUnscaledPageSize(currentPageNumber);
final rightSize = numPages > currentPageNumber
? _getUnscaledPageSize(currentPageNumber + 1)
: leftSize;
// Combine pages for scaling calculation
final combinedSize = Size(
leftSize.width + rightSize.width,
max(leftSize.height, rightSize.height),
);
final scaledCombined = _scaleToFit(parentSize, combinedSize);
final scaleFactor = scaledCombined.width / combinedSize.width;
return (leftSize * scaleFactor, rightSize * scaleFactor);
}
Size _calculateSinglePageSize(Size parentSize) {
return _scaleToFit(parentSize, _getUnscaledPageSize(currentPageNumber));
}
Size _getUnscaledPageSize(int pageNumber) {
return document.pages.elementAt(pageNumber - 1).size;
}
/// Scales a page size to fit within parent bounds while maintaining aspect ratio.
Size _scaleToFit(Size parentSize, Size pageSize) {
// Determine if height or width is the limiting factor
if (parentSize.aspectRatio > pageSize.aspectRatio) {
// Constrained by height
final height = parentSize.height;
final width = height * pageSize.aspectRatio;
return Size(width, height);
} else {
// Constrained by width
final width = parentSize.width;
final height = width / pageSize.aspectRatio;
return Size(width, height);
}
}
}
@@ -0,0 +1,126 @@
import 'package:flutter/material.dart';
import '../../../core/models/config.dart';
import 'pdf_page_display.dart';
/// Callback for page turn events.
typedef PageTurnCallback = dynamic Function(int delta);
/// Gesture layer for touch-based navigation over PDF pages.
///
/// Touch zones:
/// - Top 2cm: Toggle fullscreen (or exit if in fullscreen + top-right corner)
/// - Left side: Turn page backward (-1 or -2 in two-page mode)
/// - Right side: Turn page forward (+1 or +2 in two-page mode)
class TouchNavigationLayer extends StatelessWidget {
final PdfPageDisplay pageDisplay;
final Config config;
final VoidCallback onToggleFullscreen;
final VoidCallback onExit;
final PageTurnCallback onPageTurn;
const TouchNavigationLayer({
super.key,
required this.pageDisplay,
required this.config,
required this.onToggleFullscreen,
required this.onExit,
required this.onPageTurn,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTapUp: (details) => _handleTap(context, details),
child: pageDisplay,
);
}
void _handleTap(BuildContext context, TapUpDetails details) {
final mediaQuery = MediaQuery.of(context);
final position = details.localPosition;
// Calculate physical measurements for consistent touch zones
final pixelsPerCm = _calculatePixelsPerCm(mediaQuery.devicePixelRatio);
final touchZoneHeight = 2 * pixelsPerCm;
final touchZoneWidth = 2 * pixelsPerCm;
final screenWidth = mediaQuery.size.width;
final screenCenter = screenWidth / 2;
// Get page sizes for accurate touch zone calculation
final (leftPageSize, rightPageSize) = pageDisplay.calculateScaledPageSizes(
mediaQuery.size,
);
// Check top zone first
if (position.dy < touchZoneHeight) {
_handleTopZoneTap(position, screenWidth, touchZoneWidth);
return;
}
// Handle page turning based on tap position
_handlePageTurnTap(position, screenCenter, leftPageSize, rightPageSize);
}
void _handleTopZoneTap(
Offset position,
double screenWidth,
double touchZoneWidth,
) {
// Top-right corner in fullscreen mode = exit
if (config.fullscreen && position.dx >= screenWidth - touchZoneWidth) {
onExit();
} else {
onToggleFullscreen();
}
}
void _handlePageTurnTap(
Offset position,
double screenCenter,
Size leftPageSize,
Size? rightPageSize,
) {
final isLeftSide = position.dx < screenCenter;
if (config.twoPageMode) {
_handleTwoPageModeTap(
position,
screenCenter,
leftPageSize,
rightPageSize,
);
} else {
// Single page mode: simple left/right
onPageTurn(isLeftSide ? -1 : 1);
}
}
void _handleTwoPageModeTap(
Offset position,
double screenCenter,
Size leftPageSize,
Size? rightPageSize,
) {
final leftEdge = screenCenter - leftPageSize.width / 2;
final rightEdge = screenCenter + (rightPageSize?.width ?? 0) / 2;
if (position.dx < leftEdge) {
onPageTurn(-2);
} else if (position.dx < screenCenter) {
onPageTurn(-1);
} else if (position.dx > rightEdge) {
onPageTurn(2);
} else {
onPageTurn(1);
}
}
double _calculatePixelsPerCm(double devicePixelRatio) {
const baseDpi = 160.0; // Android baseline DPI
const cmPerInch = 2.54;
return (devicePixelRatio * baseDpi) / cmPerInch;
}
}
-236
View File
@@ -1,236 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_fullscreen/flutter_fullscreen.dart';
import 'package:logging/logging.dart';
import 'package:sheetless/login_page.dart';
import 'package:sheetless/main.dart';
import 'package:sheetless/sheet_viewer_page.dart';
import 'package:sheetless/storage_helper.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'api.dart';
import 'sheet.dart';
class MyHomePage extends StatefulWidget {
final Config config;
const MyHomePage({super.key, required this.config});
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> with RouteAware {
ApiClient? apiClient;
Future<bool> apiLoggedIn = Future.value(false);
final StorageHelper _storageHelper = StorageHelper();
final log = Logger("MyHomePage");
String? appName;
String? appVersion;
bool shuffling = false;
late Future<List<Sheet>> sheets;
@override
void initState() {
// Deactivate fullscreen by default
FullScreen.setFullScreen(false);
// So RouteAware gets bound
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
routeObserver.subscribe(this, ModalRoute.of(context)!);
});
super.initState();
_loadAppInfo();
sheets = acquireSheets();
}
@override
void dispose() {
super.dispose();
}
@override
void didPush() {
FullScreen.setFullScreen(false);
super.didPush();
}
@override
void didPopNext() {
FullScreen.setFullScreen(false);
super.didPopNext();
}
Future<void> _loadAppInfo() async {
final info = await PackageInfo.fromPlatform();
setState(() {
appName = info.appName;
appVersion = info.version;
});
}
Future<List<Sheet>> acquireSheets() async {
final url = await _storageHelper.readSecure(SecureStorageKey.url);
final jwt = await _storageHelper.readSecure(SecureStorageKey.jwt);
apiClient = ApiClient(baseUrl: url!, token: jwt);
// TODO: check if really logged in
final sheets = await apiClient!.fetchSheets();
log.info("${sheets.length} sheets fetched");
final sheetsSorted = await sortSheetsByRecency(sheets);
log.info("${sheetsSorted.length} sheets sorted");
// TODO: make work
// final changeQueue = await _storageHelper.readChangeQueue();
// changeQueue.applyToSheets(sheetsSorted);
// log.info("${changeQueue.length()} changes applied");
return sheetsSorted;
}
Future<List<Sheet>> sortSheetsByRecency(List<Sheet> sheets) async {
final accessTimes = await _storageHelper.readSheetAccessTimes();
sheets.sort((a, b) {
var dateA = accessTimes[a.uuid];
var dateB = accessTimes[b.uuid];
if (dateA == null || a.updatedAt.isAfter(dateA)) {
dateA = a.updatedAt;
}
if (dateB == null || b.updatedAt.isAfter(dateB)) {
dateB = b.updatedAt;
}
return dateB.compareTo(dateA);
});
return sheets;
}
Future<void> _refreshSheets() async {
setState(() {
sheets = acquireSheets();
});
}
Future<void> _logOut() async {
// Delete saved jwt
await _storageHelper.writeSecure(SecureStorageKey.jwt, null);
if (!mounted) return; // Widget already removed
Navigator.of(
context,
).pushReplacement(MaterialPageRoute(builder: (_) => LoginPage()));
}
void switchShufflingState(bool newState) async {
if (newState) {
(await sheets).shuffle();
} else {
sheets = sortSheetsByRecency(await sheets);
}
setState(() {
shuffling = newState;
});
}
Drawer _buildDrawer() {
return Drawer(
child: SafeArea(
child: Padding(
padding: EdgeInsetsGeometry.directional(start: 10, end: 10, top: 30),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Drawer Actions
Column(
children: [
ListTile(
leading: Icon(
Icons.shuffle,
color: shuffling ? Colors.blue : null,
),
title: const Text('Shuffle'),
onTap: () {
switchShufflingState(!shuffling);
},
),
ListTile(
leading: const Icon(Icons.logout),
title: const Text('Logout'),
onTap: _logOut,
),
],
),
// App Info at bottom
Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
'$appName v$appVersion',
style: const TextStyle(color: Colors.grey),
),
),
],
),
),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
// Icon for drawer appears automatically
appBar: AppBar(title: const Text("Sheetless")),
endDrawer: _buildDrawer(),
body: RefreshIndicator(
onRefresh: _refreshSheets,
child: FutureBuilder(
future: sheets,
builder: (BuildContext context, AsyncSnapshot<List<Sheet>> snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasData) {
return SheetsWidget(
sheets: snapshot.data!,
onSheetOpenRequest: (sheet) {
_storageHelper.writeSheetAccessTime(
sheet.uuid,
DateTime.now(),
);
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => SheetViewerPage(
sheet: sheet,
apiClient: apiClient!,
config: widget.config,
),
),
);
},
);
} else if (snapshot.hasError) {
log.warning("Error loading sheets:", snapshot.error);
return Center(
child: Text(
style: Theme.of(
context,
).textTheme.displaySmall!.copyWith(color: Colors.red),
textAlign: TextAlign.center,
snapshot.error.toString(),
),
);
} else {
return const Center(child: CircularProgressIndicator());
}
},
),
),
);
}
}
-159
View File
@@ -1,159 +0,0 @@
import 'package:flutter/material.dart';
import 'package:jwt_decoder/jwt_decoder.dart';
import 'package:logging/logging.dart';
import 'package:sheetless/api.dart';
import 'package:sheetless/home_page.dart';
import 'package:sheetless/storage_helper.dart';
class LoginPage extends StatefulWidget {
const LoginPage({super.key});
@override
State<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> {
final log = Logger("_LoginPageState");
final TextEditingController _urlController = TextEditingController(
text: "https://sheetable.julian-mutter.de",
);
final TextEditingController _usernameController = TextEditingController();
final TextEditingController _passwordController = TextEditingController();
final _formKey = GlobalKey<FormState>();
final StorageHelper _storageHelper = StorageHelper();
String? _error;
bool loggingIn = false;
@override
void initState() {
super.initState();
_restoreStoredValues();
}
Future<void> _restoreStoredValues() async {
final jwt = await _storageHelper.readSecure(SecureStorageKey.jwt);
if (jwt != null && await _isJwtValid(jwt)) {
await _navigateToMainPage();
return;
}
final url = await _storageHelper.readSecure(SecureStorageKey.url);
final username = await _storageHelper.readSecure(SecureStorageKey.email);
if (url != null) {
_urlController.text = url;
}
if (username != null) {
_usernameController.text = username;
}
}
Future<bool> _isJwtValid(String jwt) async {
try {
bool expired = JwtDecoder.isExpired(jwt);
return !expired;
} on FormatException {
return false;
}
}
Future<void> _login(
String serverUrl, String username, String password) async {
setState(() {
_error = null;
});
final apiClient = ApiClient(baseUrl: serverUrl);
try {
await apiClient.login(username, password);
await _storageHelper.writeSecure(SecureStorageKey.url, serverUrl);
await _storageHelper.writeSecure(SecureStorageKey.jwt, apiClient.token!);
await _storageHelper.writeSecure(SecureStorageKey.email, username);
await _navigateToMainPage();
} catch (e) {
setState(() {
_error = "Login failed.\n$e";
});
}
}
Future<void> _navigateToMainPage() async {
final config = await _storageHelper.readConfig();
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => MyHomePage(config: config)),
);
}
String? validateNotEmpty(String? content) {
if (content == null || content.isEmpty) {
return "Do not leave this field empty";
}
return null;
}
void handleLoginPressed() async {
if (loggingIn) return;
loggingIn = true;
if (_formKey.currentState!.validate()) {
await _login(
_urlController.text,
_usernameController.text,
_passwordController.text,
);
}
loggingIn = false;
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Login')),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TextFormField(
controller: _urlController,
validator: validateNotEmpty,
decoration: InputDecoration(labelText: 'Url'),
textInputAction: TextInputAction.next,
),
TextFormField(
controller: _usernameController,
validator: validateNotEmpty,
decoration: InputDecoration(labelText: 'Username'),
textInputAction: TextInputAction.next,
),
TextFormField(
controller: _passwordController,
validator: validateNotEmpty,
// focusNode: _passwordFocusNode,
decoration: InputDecoration(labelText: 'Password'),
obscureText: true,
textInputAction: TextInputAction
.next, // with submit or go, onFieldSubmitted is not called
onFieldSubmitted: (_) => handleLoginPressed(),
),
// ),
SizedBox(height: 5),
ElevatedButton(
onPressed: loggingIn ? null : handleLoginPressed,
child: Text('Login'),
),
if (_error != null)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(_error!, style: TextStyle(color: Colors.red)),
),
],
),
),
),
);
}
}
+25 -27
View File
@@ -1,49 +1,47 @@
import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_fullscreen/flutter_fullscreen.dart';
import 'package:hive/hive.dart'; import 'package:hive/hive.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:pdfrx/pdfrx.dart'; import 'package:pdfrx/pdfrx.dart';
import 'package:flutter_fullscreen/flutter_fullscreen.dart';
import 'login_page.dart'; import 'app.dart';
final RouteObserver<ModalRoute> routeObserver = RouteObserver<ModalRoute>(); /// Application entry point.
///
/// Performs platform-specific initialization before running the app
Future<void> main() async { Future<void> main() async {
Logger.root.level = Level.ALL; // defaults to Level.INFO WidgetsFlutterBinding.ensureInitialized();
await _initializeLogging();
await _initializePlugins();
runApp(const SheetlessApp());
}
/// Configures the logging system.
Future<void> _initializeLogging() async {
Logger.root.level = Level.ALL;
Logger.root.onRecord.listen((record) { Logger.root.onRecord.listen((record) {
debugPrint('${record.level.name}: ${record.time}: ${record.message}'); debugPrint('${record.level.name}: ${record.time}: ${record.message}');
if (record.error != null) { if (record.error != null) {
debugPrint('${record.error}'); debugPrint('${record.error}');
} }
}); });
}
// setup for flutter_fullscreen /// Initializes platform-specific plugins and services.
WidgetsFlutterBinding.ensureInitialized(); // Needs to be initialized first, otherwise app gets stuck in splash screen Future<void> _initializePlugins() async {
// Fullscreen support
await FullScreen.ensureInitialized(); await FullScreen.ensureInitialized();
pdfrxFlutterInitialize(); // Needed especially for web // PDF rendering (especially needed for web)
pdfrxFlutterInitialize();
// Local storage (not supported or necessary on web)
if (!kIsWeb) { if (!kIsWeb) {
Directory dir = await getApplicationDocumentsDirectory(); final dir = await getApplicationDocumentsDirectory();
Hive.init(dir.path); // Needed only if not web Hive.init(dir.path);
}
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Sheetless',
theme: ThemeData(useMaterial3: true, primarySwatch: Colors.blue),
home: const LoginPage(),
navigatorObservers: [routeObserver],
);
} }
} }
+40
View File
@@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// Widget that handles Bluetooth pedal and keyboard shortcuts for page turning.
///
/// Listens for arrow key presses and invokes the appropriate callback:
/// - Arrow Down/Right: Turn page forward
/// - Arrow Up/Left: Turn page backward
class PedalShortcuts extends StatelessWidget {
final Widget child;
final VoidCallback onPageForward;
final VoidCallback onPageBackward;
const PedalShortcuts({
super.key,
required this.child,
required this.onPageForward,
required this.onPageBackward,
});
@override
Widget build(BuildContext context) {
return CallbackShortcuts(
bindings: _buildBindings(),
child: Focus(autofocus: true, child: child),
);
}
Map<ShortcutActivator, VoidCallback> _buildBindings() {
return {
// Forward navigation
const SingleActivator(LogicalKeyboardKey.arrowDown): onPageForward,
const SingleActivator(LogicalKeyboardKey.arrowRight): onPageForward,
// Backward navigation
const SingleActivator(LogicalKeyboardKey.arrowUp): onPageBackward,
const SingleActivator(LogicalKeyboardKey.arrowLeft): onPageBackward,
};
}
}
@@ -0,0 +1,103 @@
import 'package:flutter/material.dart';
import '../../core/models/sheet.dart';
/// Callback when sheet metadata is saved.
typedef SheetEditCallback = void Function(String newName, String newComposer);
/// Bottom sheet for editing sheet metadata (name and composer).
class EditSheetBottomSheet extends StatefulWidget {
final Sheet sheet;
final SheetEditCallback onSave;
const EditSheetBottomSheet({
super.key,
required this.sheet,
required this.onSave,
});
@override
State<EditSheetBottomSheet> createState() => _EditSheetBottomSheetState();
}
class _EditSheetBottomSheetState extends State<EditSheetBottomSheet> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _nameController;
late final TextEditingController _composerController;
@override
void initState() {
super.initState();
_nameController = TextEditingController(text: widget.sheet.name);
_composerController = TextEditingController(text: widget.sheet.composer);
}
@override
void dispose() {
_nameController.dispose();
_composerController.dispose();
super.dispose();
}
void _handleSave() {
if (!_formKey.currentState!.validate()) return;
widget.onSave(_nameController.text.trim(), _composerController.text.trim());
Navigator.pop(context);
}
String? _validateNotEmpty(String? value) {
if (value == null || value.trim().isEmpty) {
return 'This field cannot be empty';
}
return null;
}
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.fromLTRB(
16,
16,
16,
MediaQuery.of(context).viewInsets.bottom + 16,
),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Edit Sheet',
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
TextFormField(
controller: _nameController,
validator: _validateNotEmpty,
decoration: const InputDecoration(
labelText: 'Sheet Name',
border: OutlineInputBorder(),
),
textInputAction: TextInputAction.next,
),
const SizedBox(height: 12),
TextFormField(
controller: _composerController,
decoration: const InputDecoration(
labelText: 'Composer',
border: OutlineInputBorder(),
),
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => _handleSave(),
),
const SizedBox(height: 16),
ElevatedButton(onPressed: _handleSave, child: const Text('Save')),
],
),
),
);
}
}
-185
View File
@@ -1,185 +0,0 @@
import 'dart:async';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:sheetless/edit_bottom_sheet.dart';
import 'package:sheetless/storage_helper.dart';
import 'package:sheetless/upload_queue.dart';
class Sheet {
final String uuid;
String name;
String composerUuid;
String composerName;
DateTime updatedAt;
Sheet({
required this.uuid,
required this.name,
required this.composerUuid,
required this.composerName,
required this.updatedAt,
});
// Factory constructor for creating a Sheet from JSON
factory Sheet.fromJson(Map<String, dynamic> json) {
final composer = json['composer'] as Map<String, dynamic>?;
return Sheet(
uuid: json['uuid'].toString(),
name: json['title'],
composerUuid: json['composer_uuid']?.toString() ?? '',
composerName: composer?['name'] ?? 'Unknown',
updatedAt: DateTime.parse(json['updated_at']),
);
}
}
class SheetsWidget extends StatefulWidget {
final List<Sheet> sheets;
final ValueSetter<Sheet> onSheetOpenRequest;
const SheetsWidget({
super.key,
required this.sheets,
required this.onSheetOpenRequest,
});
@override
State<SheetsWidget> createState() => _SheetsWidgetState();
}
class _SheetsWidgetState extends State<SheetsWidget> {
late List<Sheet> filteredSheets;
final StorageHelper storageHelper = StorageHelper();
final TextEditingController _searchController = TextEditingController();
Timer? _debounce;
@override
void initState() {
super.initState();
filteredSheets = widget.sheets;
_searchController.addListener(_onSearchChanged);
}
@override
void dispose() {
_searchController.removeListener(_onSearchChanged);
_searchController.dispose();
_debounce?.cancel();
super.dispose();
}
void _onSearchChanged() {
if (_debounce?.isActive ?? false) _debounce!.cancel();
_debounce = Timer(const Duration(milliseconds: 500), () {
_filterSheets();
});
}
void _filterSheets() {
setState(() {
String query = _searchController.text.toLowerCase().trim();
List<String> terms = query.split(RegExp(r'\s+')); // Split by whitespace
filteredSheets = widget.sheets.where((sheet) {
String name = sheet.name.toLowerCase();
String composer = sheet.composerName.toLowerCase();
// Each term must be found in either the name or composer
return terms.every(
(term) => name.contains(term) || composer.contains(term),
);
}).toList();
});
}
void _clearSearch() {
_searchController.clear();
}
void _openEditSheet(BuildContext context, Sheet sheet) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (_) => EditItemBottomSheet(
sheet: sheet,
onSheetEdited: (String newName, String newComposer) {
if (newName != sheet.name) {
storageHelper.writeChange(
Change(
type: ChangeType.sheetNameChange,
sheetUuid: sheet.uuid,
value: newName,
),
);
}
if (newComposer != sheet.composerName) {
storageHelper.writeChange(
Change(
type: ChangeType.composerNameChange,
sheetUuid: sheet.uuid,
value: newComposer,
),
);
}
setState(() {
sheet.name = newName;
sheet.composerName = newComposer;
});
},
),
);
}
@override
Widget build(BuildContext context) {
return Column(
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search...',
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: _clearSearch,
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8.0),
),
),
),
),
Expanded(
// Fixes scroll on web
child: ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(
dragDevices: {PointerDeviceKind.touch, PointerDeviceKind.mouse},
),
child: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
itemCount: filteredSheets.length,
itemBuilder: (context, index) {
var sheet = filteredSheets[index];
return ListTile(
title: Text(sheet.name),
subtitle: Text(sheet.composerName),
onTap: () => setState(() {
widget.onSheetOpenRequest(sheet);
widget.sheets.remove(sheet);
widget.sheets.insert(0, sheet);
}),
onLongPress: () => _openEditSheet(context, sheet),
);
},
),
),
),
],
);
}
}
-426
View File
@@ -1,426 +0,0 @@
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_drawing_board/flutter_drawing_board.dart';
import 'package:logging/logging.dart';
import 'package:pdfrx/pdfrx.dart';
import 'package:sheetless/api.dart';
import 'package:sheetless/bt_pedal_shortcuts.dart';
import 'package:sheetless/sheet.dart';
import 'package:sheetless/storage_helper.dart';
import 'package:flutter_fullscreen/flutter_fullscreen.dart';
class SheetViewerPage extends StatefulWidget {
final Sheet sheet;
final ApiClient apiClient;
final Config config;
const SheetViewerPage({
super.key,
required this.sheet,
required this.apiClient,
required this.config,
});
@override
State<SheetViewerPage> createState() => _SheetViewerPageState();
}
class _SheetViewerPageState extends State<SheetViewerPage>
with FullScreenListener {
final log = Logger("SheetViewerPage");
final StorageHelper storageHelper = StorageHelper();
int currentPageNumber = 1;
int numPages = 1;
late Future<bool> documentLoaded;
PdfDocument? document;
bool paintMode = false;
Pages? pages;
@override
void initState() {
FullScreen.addListener(this);
// Load saved fullscreen
FullScreen.setFullScreen(widget.config.fullscreen);
super.initState();
documentLoaded = loadPdf();
}
@override
void dispose() {
FullScreen.removeListener(this);
document?.dispose(); // Make sure document gets garbage collected
super.dispose();
}
Future<bool> loadPdf() async {
if (kIsWeb) {
final data = await widget.apiClient.fetchPdfFileData(widget.sheet.uuid);
document = await PdfDocument.openData(data);
} else {
final file = await widget.apiClient.getPdfFileCached(widget.sheet.uuid);
document = await PdfDocument.openFile(file.path);
}
setState(() {
// document changed
});
return true;
}
@override
void onFullScreenChanged(bool enabled, SystemUiMode? systemUiMode) {
setState(() {
widget.config.fullscreen = enabled;
storageHelper.writeConfig(widget.config);
});
}
void toggleFullscreen() {
FullScreen.setFullScreen(!widget.config.fullscreen);
}
void turnPage(int numTurns) {
setState(() {
currentPageNumber += numTurns;
currentPageNumber = currentPageNumber.clamp(1, numPages);
});
}
AppBar? buildAppBar() {
if (widget.config.fullscreen && document != null) {
return null;
}
return AppBar(
title: Text(widget.sheet.name),
actions: [
IconButton(
icon: Icon(
widget.config.fullscreen ? Icons.fullscreen_exit : Icons.fullscreen,
),
tooltip: widget.config.fullscreen
? 'Exit Fullscreen'
: 'Enter Fullscreen',
onPressed: () {
toggleFullscreen();
},
),
IconButton(
onPressed: () {
setState(() {
if (widget.config.twoPageMode) {
// TODO: notification that paint mode only in single page mode
} else {
paintMode = !paintMode;
}
});
},
icon: Icon(Icons.brush),
),
IconButton(
onPressed: () {
setState(() {
widget.config.twoPageMode = !widget.config.twoPageMode;
storageHelper.writeConfig(widget.config);
if (widget.config.twoPageMode) {
paintMode = false;
// TODO: notification that paint mode was deactivated since only possible in single page mode
}
});
},
icon: Icon(
widget.config.twoPageMode ? Icons.filter_1 : Icons.filter_2,
),
),
],
);
}
@override
Widget build(BuildContext context) {
return BtPedalShortcuts(
onTurnPageForward: () => turnPage(1),
onTurnPageBackward: () => turnPage(-1),
child: Scaffold(
appBar: buildAppBar(),
body: FutureBuilder(
future: documentLoaded,
builder: (BuildContext context, AsyncSnapshot<bool> snapshot) {
if (snapshot.hasData && document != null) {
numPages = document!.pages.length;
pages = Pages(
document: document!,
numPages: numPages,
config: widget.config,
currentPageNumber: currentPageNumber,
);
return Stack(
children: [
Stack(
children: [
Visibility(
visible: !paintMode,
child: TouchablePages(
pages: pages!,
onToggleFullscreen: () {
toggleFullscreen();
},
onExitSheetViewer: () {
Navigator.pop(context);
},
onTurnPage: (int numTurns) {
turnPage(numTurns);
},
),
),
Visibility(
visible: paintMode,
child: PaintablePages(pages: pages!),
),
],
),
],
);
} else if (snapshot.hasError) {
log.warning("Error loading pdf:", snapshot.error);
return Center(
child: Text(
style: Theme.of(
context,
).textTheme.displaySmall!.copyWith(color: Colors.red),
textAlign: TextAlign.center,
snapshot.error.toString(),
),
);
} else {
return const Center(child: CircularProgressIndicator());
}
},
),
),
);
}
}
typedef PageturnCallback = void Function(int numTurns);
class TouchablePages extends StatelessWidget {
final Pages pages;
final VoidCallback onToggleFullscreen;
final VoidCallback onExitSheetViewer;
final PageturnCallback onTurnPage;
const TouchablePages({
super.key,
required this.pages,
required this.onToggleFullscreen,
required this.onExitSheetViewer,
required this.onTurnPage,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior:
HitTestBehavior.opaque, // Also register when outside of pdf pages
onTapUp: (TapUpDetails details) {
final mediaQuery = MediaQuery.of(context);
final pixelsPerInch =
mediaQuery.devicePixelRatio *
160; // 160 dpi = 1 logical inch baseline
final pixelsPerCm = pixelsPerInch / 2.54;
final touchAreaWidth = mediaQuery.size.width;
final (leftPageSize, rightPageSize) = pages.calcPageSizesScaled(
mediaQuery.size,
);
if (details.localPosition.dy < 2 * pixelsPerCm &&
details.localPosition.dx >= touchAreaWidth - 2 * pixelsPerCm &&
pages.config.fullscreen) {
onExitSheetViewer();
} else if (details.localPosition.dy < 2 * pixelsPerCm) {
onToggleFullscreen();
} else if (pages.config.twoPageMode &&
details.localPosition.dx <
touchAreaWidth / 2 - leftPageSize.width / 2) {
onTurnPage(-2);
} else if (details.localPosition.dx < touchAreaWidth / 2) {
onTurnPage(-1);
} else if (pages.config.twoPageMode &&
details.localPosition.dx >
touchAreaWidth / 2 + rightPageSize!.width / 2) {
onTurnPage(2);
} else {
onTurnPage(1);
}
},
child: pages,
);
}
}
class PaintablePages extends StatelessWidget {
final Pages pages;
const PaintablePages({super.key, required this.pages});
@override
Widget build(BuildContext context) {
return SizedBox.expand(
child: LayoutBuilder(
builder: (context, constraints) {
final maxSize = Size(constraints.maxWidth, constraints.maxHeight);
final (pageSizeScaled, _) = pages.calcPageSizesScaled(maxSize);
return DrawingBoard(
background: SizedBox(
width: pageSizeScaled.width,
height: pageSizeScaled.height,
child: pages,
),
// showDefaultTools: true,
// showDefaultActions: true,
boardConstrained: true,
minScale: 1,
maxScale: 3,
alignment: Alignment.topRight,
boardBoundaryMargin: EdgeInsets.all(0),
);
},
),
);
}
}
class Pages extends StatelessWidget {
final PdfDocument document;
final int numPages;
final int currentPageNumber; // Starts at 1
final Config config;
const Pages({
super.key,
required this.document,
required this.numPages,
required this.currentPageNumber,
required this.config,
});
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
spacing: 0,
children: [
Expanded(
child: Stack(
children: [
PdfPageView(
key: ValueKey(currentPageNumber),
document: document,
pageNumber: currentPageNumber,
maximumDpi: 300,
alignment: config.twoPageMode && numPages >= 2
? Alignment.centerRight
: Alignment.center,
),
Positioned.fill(
child: Container(
alignment: Alignment.bottomCenter,
padding: EdgeInsets.only(bottom: 5),
child: Text('$currentPageNumber / $numPages'),
),
),
],
),
),
Visibility(
visible: config.twoPageMode == true && numPages >= 2,
child: Expanded(
child: Stack(
children: [
PdfPageView(
key: ValueKey(currentPageNumber + 1),
document: document,
pageNumber: currentPageNumber + 1,
maximumDpi: 300,
alignment: Alignment.centerLeft,
// alignment: Alignment.center,
),
Positioned.fill(
child: Container(
alignment: Alignment.bottomCenter,
padding: EdgeInsets.only(bottom: 5),
child: Text('${currentPageNumber + 1} / $numPages'),
),
),
],
),
),
),
],
);
}
(Size, Size?) calcPageSizesScaled(Size parentSize) {
if (config.twoPageMode) {
Size leftPageSizeUnscaled = _getPageSizeUnscaled(currentPageNumber);
Size rightPageSizeUnscaled;
if (numPages > currentPageNumber) {
rightPageSizeUnscaled = _getPageSizeUnscaled(currentPageNumber + 1);
} else {
rightPageSizeUnscaled = leftPageSizeUnscaled;
}
Size combinedPageSizesUnscaled = Size(
leftPageSizeUnscaled.width + rightPageSizeUnscaled.width,
max(leftPageSizeUnscaled.height, rightPageSizeUnscaled.height),
);
Size combinedPageSizesScaled = _calcScaledPageSize(
parentSize,
combinedPageSizesUnscaled,
);
double scaleFactor =
combinedPageSizesScaled.width / combinedPageSizesUnscaled.width;
return (
leftPageSizeUnscaled * scaleFactor,
rightPageSizeUnscaled * scaleFactor,
);
} else {
return (
_calcScaledPageSize(
parentSize,
_getPageSizeUnscaled(currentPageNumber),
),
null,
);
}
}
Size _getPageSizeUnscaled(int pageNumber) {
return document.pages.elementAt(pageNumber - 1).size;
}
Size _calcScaledPageSize(Size parentSize, Size pageSize) {
// page restricted by height
if (parentSize.aspectRatio > pageSize.aspectRatio) {
final height = parentSize.height;
final width = height * pageSize.aspectRatio;
return Size(width, height);
}
// page restricted by width
else {
final width = parentSize.width;
final height = width / pageSize.aspectRatio;
return Size(width, height);
}
}
}
-84
View File
@@ -1,84 +0,0 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:hive/hive.dart';
import 'package:sheetless/upload_queue.dart';
enum SecureStorageKey { url, jwt, email }
enum ConfigKey { twoPageMode }
class Config {
Config({required this.twoPageMode, required this.fullscreen});
static const String keyTwoPageMode = "twoPageMode";
static const String keyFullscreen = "fullscreen";
bool twoPageMode;
bool fullscreen;
}
class StorageHelper {
final sheetAccessTimesBox = "sheetAccessTimes";
final configBox = "config";
final changeQueueBox = "changeQueue";
late FlutterSecureStorage secureStorage;
StorageHelper() {
AndroidOptions getAndroidOptions() =>
const AndroidOptions(encryptedSharedPreferences: true);
secureStorage = FlutterSecureStorage(aOptions: getAndroidOptions());
}
Future<String?> readSecure(SecureStorageKey key) {
return secureStorage.read(key: key.name);
}
Future<void> writeSecure(SecureStorageKey key, String? value) {
return secureStorage.write(key: key.name, value: value);
}
Future<Config> readConfig() async {
final box = await Hive.openBox(configBox);
return Config(
twoPageMode: box.get(Config.keyTwoPageMode) ?? false,
fullscreen: box.get(Config.keyFullscreen) ?? false,
);
}
Future<void> writeConfig(Config config) async {
final box = await Hive.openBox(configBox);
box.put(Config.keyTwoPageMode, config.twoPageMode);
box.put(Config.keyFullscreen, config.fullscreen);
}
Future<Map<String, DateTime>> readSheetAccessTimes() async {
final box = await Hive.openBox(sheetAccessTimesBox);
return box.toMap().map(
(k, v) => MapEntry(k as String, DateTime.parse(v as String)),
);
}
Future<void> writeSheetAccessTime(String uuid, DateTime datetime) async {
final box = await Hive.openBox(sheetAccessTimesBox);
await box.put(uuid, datetime.toIso8601String());
}
Future<void> writeChange(Change change) async {
final box = await Hive.openBox(changeQueueBox);
box.add(change.toMap());
}
Future<ChangeQueue> readChangeQueue() async {
final box = await Hive.openBox(changeQueueBox);
ChangeQueue queue = ChangeQueue();
for (Map<dynamic, dynamic> map in box.values) {
queue.addChange(Change.fromMap(map));
}
return queue;
}
Future<void> deleteOldestChange(Change change) async {
final box = await Hive.openBox(changeQueueBox);
box.deleteAt(0);
}
}
-66
View File
@@ -1,66 +0,0 @@
import 'dart:collection';
import 'package:sheetless/sheet.dart';
enum ChangeType {
sheetNameChange,
composerNameChange,
addTagChange,
removeTagChange,
}
class Change {
final ChangeType type;
final String sheetUuid;
final String value;
Change({required this.type, required this.sheetUuid, required this.value});
Map<String, dynamic> toMap() => {
"type": type.index,
"sheetUuid": sheetUuid,
"value": value,
};
factory Change.fromMap(Map<dynamic, dynamic> map) {
return Change(
type: ChangeType
.values[map["type"]], // TODO: this will create problems if new enums are added
sheetUuid: map["sheetUuid"],
value: map["value"],
);
}
}
class ChangeQueue {
// Queue with oldest change first
final Queue<Change> queue = Queue();
ChangeQueue() {}
void addChange(Change change) {
queue.addLast(change);
}
int length() {
return queue.length;
}
void applyToSheets(List<Sheet> sheets) {
for (Change change in queue) {
var sheet = sheets.where((sheet) => sheet.uuid == change.sheetUuid).first;
switch (change.type) {
case ChangeType.sheetNameChange:
sheet.name = change.value;
case ChangeType.composerNameChange:
sheet.composerName = change.value;
case ChangeType.addTagChange:
// TODO: Handle this case.
throw UnimplementedError();
case ChangeType.removeTagChange:
// TODO: Handle this case.
throw UnimplementedError();
}
}
}
}
+10 -20
View File
@@ -1,5 +1,5 @@
# Project-level configuration. # Project-level configuration.
cmake_minimum_required(VERSION 3.10) cmake_minimum_required(VERSION 3.13)
project(runner LANGUAGES CXX) project(runner LANGUAGES CXX)
# The name of the executable created for the application. Change this to change # The name of the executable created for the application. Change this to change
@@ -54,25 +54,8 @@ add_subdirectory(${FLUTTER_MANAGED_DIR})
find_package(PkgConfig REQUIRED) find_package(PkgConfig REQUIRED)
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") # Application build; see runner/CMakeLists.txt.
add_subdirectory("runner")
# Define the application target. To change its name, change BINARY_NAME above,
# not the value here, or `flutter run` will no longer work.
#
# Any new source files that you add to the application should be added here.
add_executable(${BINARY_NAME}
"main.cc"
"my_application.cc"
"${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc"
)
# Apply the standard set of build settings. This can be removed for applications
# that need different build settings.
apply_standard_settings(${BINARY_NAME})
# Add dependency libraries. Add any application-specific dependencies here.
target_link_libraries(${BINARY_NAME} PRIVATE flutter)
target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK)
# Run the Flutter tool portions of the build. This must not be removed. # Run the Flutter tool portions of the build. This must not be removed.
add_dependencies(${BINARY_NAME} flutter_assemble) add_dependencies(${BINARY_NAME} flutter_assemble)
@@ -86,6 +69,7 @@ set_target_properties(${BINARY_NAME}
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run"
) )
# Generated plugin build rules, which manage building the plugins and adding # Generated plugin build rules, which manage building the plugins and adding
# them to the application. # them to the application.
include(flutter/generated_plugins.cmake) include(flutter/generated_plugins.cmake)
@@ -122,6 +106,12 @@ foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES})
COMPONENT Runtime) COMPONENT Runtime)
endforeach(bundled_library) endforeach(bundled_library)
# Copy the native assets provided by the build.dart from all packages.
set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/")
install(DIRECTORY "${NATIVE_ASSETS_DIR}"
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
# Fully re-copy the assets directory on each build to avoid having stale files # Fully re-copy the assets directory on each build to avoid having stale files
# from a previous install. # from a previous install.
set(FLUTTER_ASSET_DIR_NAME "flutter_assets") set(FLUTTER_ASSET_DIR_NAME "flutter_assets")
+1 -1
View File
@@ -10,7 +10,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST
pdfrx jni
) )
set(PLUGIN_BUNDLED_LIBRARIES) set(PLUGIN_BUNDLED_LIBRARIES)
-6
View File
@@ -1,6 +0,0 @@
#include "my_application.h"
int main(int argc, char** argv) {
g_autoptr(MyApplication) app = my_application_new();
return g_application_run(G_APPLICATION(app), argc, argv);
}
-104
View File
@@ -1,104 +0,0 @@
#include "my_application.h"
#include <flutter_linux/flutter_linux.h>
#ifdef GDK_WINDOWING_X11
#include <gdk/gdkx.h>
#endif
#include "flutter/generated_plugin_registrant.h"
struct _MyApplication {
GtkApplication parent_instance;
char** dart_entrypoint_arguments;
};
G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION)
// Implements GApplication::activate.
static void my_application_activate(GApplication* application) {
MyApplication* self = MY_APPLICATION(application);
GtkWindow* window =
GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application)));
// Use a header bar when running in GNOME as this is the common style used
// by applications and is the setup most users will be using (e.g. Ubuntu
// desktop).
// If running on X and not using GNOME then just use a traditional title bar
// in case the window manager does more exotic layout, e.g. tiling.
// If running on Wayland assume the header bar will work (may need changing
// if future cases occur).
gboolean use_header_bar = TRUE;
#ifdef GDK_WINDOWING_X11
GdkScreen* screen = gtk_window_get_screen(window);
if (GDK_IS_X11_SCREEN(screen)) {
const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen);
if (g_strcmp0(wm_name, "GNOME Shell") != 0) {
use_header_bar = FALSE;
}
}
#endif
if (use_header_bar) {
GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
gtk_widget_show(GTK_WIDGET(header_bar));
gtk_header_bar_set_title(header_bar, "sheetless");
gtk_header_bar_set_show_close_button(header_bar, TRUE);
gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
} else {
gtk_window_set_title(window, "sheetless");
}
gtk_window_set_default_size(window, 1280, 720);
gtk_widget_show(GTK_WIDGET(window));
g_autoptr(FlDartProject) project = fl_dart_project_new();
fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments);
FlView* view = fl_view_new(project);
gtk_widget_show(GTK_WIDGET(view));
gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view));
fl_register_plugins(FL_PLUGIN_REGISTRY(view));
gtk_widget_grab_focus(GTK_WIDGET(view));
}
// Implements GApplication::local_command_line.
static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) {
MyApplication* self = MY_APPLICATION(application);
// Strip out the first argument as it is the binary name.
self->dart_entrypoint_arguments = g_strdupv(*arguments + 1);
g_autoptr(GError) error = nullptr;
if (!g_application_register(application, nullptr, &error)) {
g_warning("Failed to register: %s", error->message);
*exit_status = 1;
return TRUE;
}
g_application_activate(application);
*exit_status = 0;
return TRUE;
}
// Implements GObject::dispose.
static void my_application_dispose(GObject* object) {
MyApplication* self = MY_APPLICATION(object);
g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev);
G_OBJECT_CLASS(my_application_parent_class)->dispose(object);
}
static void my_application_class_init(MyApplicationClass* klass) {
G_APPLICATION_CLASS(klass)->activate = my_application_activate;
G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line;
G_OBJECT_CLASS(klass)->dispose = my_application_dispose;
}
static void my_application_init(MyApplication* self) {}
MyApplication* my_application_new() {
return MY_APPLICATION(g_object_new(my_application_get_type(),
"application-id", APPLICATION_ID,
"flags", G_APPLICATION_NON_UNIQUE,
nullptr));
}
-18
View File
@@ -1,18 +0,0 @@
#ifndef FLUTTER_MY_APPLICATION_H_
#define FLUTTER_MY_APPLICATION_H_
#include <gtk/gtk.h>
G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION,
GtkApplication)
/**
* my_application_new:
*
* Creates a new Flutter-based application.
*
* Returns: a new #MyApplication.
*/
MyApplication* my_application_new();
#endif // FLUTTER_MY_APPLICATION_H_
+27 -9
View File
@@ -14,6 +14,11 @@ struct _MyApplication {
G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION)
// Called when first Flutter frame received.
static void first_frame_cb(MyApplication* self, FlView* view) {
gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view)));
}
// Implements GApplication::activate. // Implements GApplication::activate.
static void my_application_activate(GApplication* application) { static void my_application_activate(GApplication* application) {
MyApplication* self = MY_APPLICATION(application); MyApplication* self = MY_APPLICATION(application);
@@ -48,22 +53,35 @@ static void my_application_activate(GApplication* application) {
} }
gtk_window_set_default_size(window, 1280, 720); gtk_window_set_default_size(window, 1280, 720);
gtk_widget_show(GTK_WIDGET(window));
g_autoptr(FlDartProject) project = fl_dart_project_new(); g_autoptr(FlDartProject) project = fl_dart_project_new();
fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); fl_dart_project_set_dart_entrypoint_arguments(
project, self->dart_entrypoint_arguments);
FlView* view = fl_view_new(project); FlView* view = fl_view_new(project);
GdkRGBA background_color;
// Background defaults to black, override it here if necessary, e.g. #00000000
// for transparent.
gdk_rgba_parse(&background_color, "#000000");
fl_view_set_background_color(view, &background_color);
gtk_widget_show(GTK_WIDGET(view)); gtk_widget_show(GTK_WIDGET(view));
gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view));
// Show the window when Flutter renders.
// Requires the view to be realized so we can start rendering.
g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb),
self);
gtk_widget_realize(GTK_WIDGET(view));
fl_register_plugins(FL_PLUGIN_REGISTRY(view)); fl_register_plugins(FL_PLUGIN_REGISTRY(view));
gtk_widget_grab_focus(GTK_WIDGET(view)); gtk_widget_grab_focus(GTK_WIDGET(view));
} }
// Implements GApplication::local_command_line. // Implements GApplication::local_command_line.
static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) { static gboolean my_application_local_command_line(GApplication* application,
gchar*** arguments,
int* exit_status) {
MyApplication* self = MY_APPLICATION(application); MyApplication* self = MY_APPLICATION(application);
// Strip out the first argument as it is the binary name. // Strip out the first argument as it is the binary name.
self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); self->dart_entrypoint_arguments = g_strdupv(*arguments + 1);
@@ -83,7 +101,7 @@ static gboolean my_application_local_command_line(GApplication* application, gch
// Implements GApplication::startup. // Implements GApplication::startup.
static void my_application_startup(GApplication* application) { static void my_application_startup(GApplication* application) {
//MyApplication* self = MY_APPLICATION(object); // MyApplication* self = MY_APPLICATION(object);
// Perform any actions required at application startup. // Perform any actions required at application startup.
@@ -92,7 +110,7 @@ static void my_application_startup(GApplication* application) {
// Implements GApplication::shutdown. // Implements GApplication::shutdown.
static void my_application_shutdown(GApplication* application) { static void my_application_shutdown(GApplication* application) {
//MyApplication* self = MY_APPLICATION(object); // MyApplication* self = MY_APPLICATION(object);
// Perform any actions required at application shutdown. // Perform any actions required at application shutdown.
@@ -108,7 +126,8 @@ static void my_application_dispose(GObject* object) {
static void my_application_class_init(MyApplicationClass* klass) { static void my_application_class_init(MyApplicationClass* klass) {
G_APPLICATION_CLASS(klass)->activate = my_application_activate; G_APPLICATION_CLASS(klass)->activate = my_application_activate;
G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; G_APPLICATION_CLASS(klass)->local_command_line =
my_application_local_command_line;
G_APPLICATION_CLASS(klass)->startup = my_application_startup; G_APPLICATION_CLASS(klass)->startup = my_application_startup;
G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown;
G_OBJECT_CLASS(klass)->dispose = my_application_dispose; G_OBJECT_CLASS(klass)->dispose = my_application_dispose;
@@ -124,7 +143,6 @@ MyApplication* my_application_new() {
g_set_prgname(APPLICATION_ID); g_set_prgname(APPLICATION_ID);
return MY_APPLICATION(g_object_new(my_application_get_type(), return MY_APPLICATION(g_object_new(my_application_get_type(),
"application-id", APPLICATION_ID, "application-id", APPLICATION_ID, "flags",
"flags", G_APPLICATION_NON_UNIQUE, G_APPLICATION_NON_UNIQUE, nullptr));
nullptr));
} }
+4 -1
View File
@@ -3,7 +3,10 @@
#include <gtk/gtk.h> #include <gtk/gtk.h>
G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION, G_DECLARE_FINAL_TYPE(MyApplication,
my_application,
MY,
APPLICATION,
GtkApplication) GtkApplication)
/** /**
+2 -2
View File
@@ -1,3 +1,3 @@
# This file was automatically generated by nix-shell. # This file was automatically generated by nix-shell.
sdk.dir=/nix/store/0sbpck94abij5q1hcq1jzps3lr68p1dq-androidsdk/libexec/android-sdk sdk.dir=/nix/store/xxvb6m6dqjgdxk9xvwk8vlwapm8pgsdk-androidsdk/libexec/android-sdk
ndk.dir=/nix/store/0sbpck94abij5q1hcq1jzps3lr68p1dq-androidsdk/libexec/android-sdk/ndk-bundle ndk.dir=/nix/store/xxvb6m6dqjgdxk9xvwk8vlwapm8pgsdk-androidsdk/libexec/android-sdk/ndk-bundle
@@ -5,18 +5,16 @@
import FlutterMacOS import FlutterMacOS
import Foundation import Foundation
import flutter_secure_storage_macos import flutter_secure_storage_darwin
import package_info_plus import package_info_plus
import path_provider_foundation
import screen_retriever_macos import screen_retriever_macos
import sqflite_darwin import sqflite_darwin
import url_launcher_macos import url_launcher_macos
import window_manager import window_manager
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin")) ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
+210 -154
View File
@@ -5,10 +5,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: archive name: archive
sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.0.7" version: "4.0.9"
args: args:
dependency: transitive dependency: transitive
description: description:
@@ -21,10 +21,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: async name: async
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.13.0" version: "2.13.1"
boolean_selector: boolean_selector:
dependency: transitive dependency: transitive
description: description:
@@ -37,10 +37,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: characters name: characters
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" version: "1.4.1"
checked_yaml: checked_yaml:
dependency: transitive dependency: transitive
description: description:
@@ -65,6 +65,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.2" version: "1.1.2"
code_assets:
dependency: transitive
description:
name: code_assets
sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
url: "https://pub.dev"
source: hosted
version: "1.2.1"
collection: collection:
dependency: transitive dependency: transitive
description: description:
@@ -77,26 +85,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: crypto name: crypto
sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.6" version: "3.0.7"
cupertino_icons: cupertino_icons:
dependency: "direct main" dependency: "direct main"
description: description:
name: cupertino_icons name: cupertino_icons
sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.8" version: "1.0.9"
dart_pubspec_licenses:
dependency: transitive
description:
name: dart_pubspec_licenses
sha256: "23ddb78ff9204d08e3109ced67cd3c6c6a066f581b0edf5ee092fc3e1127f4ea"
url: "https://pub.dev"
source: hosted
version: "3.0.4"
fake_async: fake_async:
dependency: transitive dependency: transitive
description: description:
@@ -109,10 +109,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: ffi name: ffi
sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.4" version: "2.2.0"
ffi_leak_tracker:
dependency: transitive
description:
name: ffi_leak_tracker
sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97"
url: "https://pub.dev"
source: hosted
version: "0.1.2"
file: file:
dependency: transitive dependency: transitive
description: description:
@@ -142,14 +150,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.4.1" version: "3.4.1"
flutter_drawing_board:
dependency: "direct main"
description:
name: flutter_drawing_board
sha256: b7eef8c506a12485853e8b4cdc110d1d307993279b588f3a35902a7d7082dbd4
url: "https://pub.dev"
source: hosted
version: "0.9.8"
flutter_fullscreen: flutter_fullscreen:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -178,50 +178,50 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: flutter_secure_storage name: flutter_secure_storage
sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" sha256: "7686b1d6a29985dcbb808c59518226e603e3bfa7c0ddfd1a0d00e4cda77c868e"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "9.2.4" version: "10.3.1"
flutter_secure_storage_darwin:
dependency: transitive
description:
name: flutter_secure_storage_darwin
sha256: "82329fa5cdf343773b1b6897dea959105a29f092454259edff92f9f6637e8149"
url: "https://pub.dev"
source: hosted
version: "0.3.2"
flutter_secure_storage_linux: flutter_secure_storage_linux:
dependency: transitive dependency: transitive
description: description:
name: flutter_secure_storage_linux name: flutter_secure_storage_linux
sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 sha256: a5f35ddab43cf5c8215d2feb4ce1957851f28c5c37e6f04335066a0602087bf5
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.2.3" version: "3.0.1"
flutter_secure_storage_macos:
dependency: transitive
description:
name: flutter_secure_storage_macos
sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247"
url: "https://pub.dev"
source: hosted
version: "3.1.3"
flutter_secure_storage_platform_interface: flutter_secure_storage_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: flutter_secure_storage_platform_interface name: flutter_secure_storage_platform_interface
sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 sha256: "8ceea1223bee3c6ac1a22dabd8feefc550e4729b3675de4b5900f55afcb435d6"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.2" version: "2.0.1"
flutter_secure_storage_web: flutter_secure_storage_web:
dependency: transitive dependency: transitive
description: description:
name: flutter_secure_storage_web name: flutter_secure_storage_web
sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.2.1" version: "2.1.1"
flutter_secure_storage_windows: flutter_secure_storage_windows:
dependency: transitive dependency: transitive
description: description:
name: flutter_secure_storage_windows name: flutter_secure_storage_windows
sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.2" version: "4.2.2"
flutter_test: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
@@ -240,14 +240,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.3" version: "2.2.3"
hooks:
dependency: transitive
description:
name: hooks
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
http: http:
dependency: "direct main" dependency: "direct main"
description: description:
name: http name: http
sha256: "2c11f3f94c687ee9bad77c171151672986360b2b001d109814ee7140b2cf261b" sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" version: "1.6.0"
http_parser: http_parser:
dependency: transitive dependency: transitive
description: description:
@@ -260,26 +268,34 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: image name: image
sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928" sha256: "6300175e00616bbc832e2fc91bfa4d776af5402c81c7151bee6905bb08473c52"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.5.4" version: "4.9.1"
js: jni:
dependency: transitive dependency: transitive
description: description:
name: js name: jni
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.6.7" version: "1.0.0"
jni_flutter:
dependency: transitive
description:
name: jni_flutter
sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
json_annotation: json_annotation:
dependency: transitive dependency: transitive
description: description:
name: json_annotation name: json_annotation
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.9.0" version: "4.12.0"
jwt_decoder: jwt_decoder:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -292,34 +308,34 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker name: leak_tracker
sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "10.0.9" version: "11.0.2"
leak_tracker_flutter_testing: leak_tracker_flutter_testing:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker_flutter_testing name: leak_tracker_flutter_testing
sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.9" version: "3.0.10"
leak_tracker_testing: leak_tracker_testing:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker_testing name: leak_tracker_testing
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.1" version: "3.0.2"
lints: lints:
dependency: transitive dependency: transitive
description: description:
name: lints name: lints
sha256: a5e2b223cb7c9c8efdc663ef484fdd95bb243bff242ef5b13e26883547fce9a0 sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.0.0" version: "6.1.0"
logging: logging:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -332,42 +348,58 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: matcher name: matcher
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.12.17" version: "0.12.19"
material_color_utilities: material_color_utilities:
dependency: transitive dependency: transitive
description: description:
name: material_color_utilities name: material_color_utilities
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.11.1" version: "0.13.0"
meta: meta:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.16.0" version: "1.18.0"
objective_c:
dependency: transitive
description:
name: objective_c
sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed"
url: "https://pub.dev"
source: hosted
version: "9.4.1"
package_config:
dependency: transitive
description:
name: package_config
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
url: "https://pub.dev"
source: hosted
version: "2.2.0"
package_info_plus: package_info_plus:
dependency: "direct main" dependency: "direct main"
description: description:
name: package_info_plus name: package_info_plus
sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d sha256: f5c435dc0e0d461e5b32471a870f769b6a1cc46930637efe24fbc535314e78ad
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "9.0.0" version: "10.2.0"
package_info_plus_platform_interface: package_info_plus_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: package_info_plus_platform_interface name: package_info_plus_platform_interface
sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.2.1" version: "4.1.0"
path: path:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -380,42 +412,42 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: path_provider name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.5" version: "2.1.6"
path_provider_android: path_provider_android:
dependency: transitive dependency: transitive
description: description:
name: path_provider_android name: path_provider_android
sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9 sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.17" version: "2.3.1"
path_provider_foundation: path_provider_foundation:
dependency: transitive dependency: transitive
description: description:
name: path_provider_foundation name: path_provider_foundation
sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.1" version: "2.6.0"
path_provider_linux: path_provider_linux:
dependency: transitive dependency: transitive
description: description:
name: path_provider_linux name: path_provider_linux
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.1" version: "2.2.2"
path_provider_platform_interface: path_provider_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: path_provider_platform_interface name: path_provider_platform_interface
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.2" version: "2.1.3"
path_provider_windows: path_provider_windows:
dependency: transitive dependency: transitive
description: description:
@@ -424,30 +456,46 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.0" version: "2.3.0"
pdfium_dart:
dependency: transitive
description:
name: pdfium_dart
sha256: "86e95c66b09f3245b95c4924f2edce8d4f7c9786876e5c5ee8e36b104a94bbb0"
url: "https://pub.dev"
source: hosted
version: "0.2.5"
pdfium_flutter:
dependency: transitive
description:
name: pdfium_flutter
sha256: "420ba8e7673b54da387ceeeb18a72c8bc6e4452128dbb391dca900c748f9e9ba"
url: "https://pub.dev"
source: hosted
version: "0.2.2"
pdfrx: pdfrx:
dependency: "direct main" dependency: "direct main"
description: description:
name: pdfrx name: pdfrx
sha256: a88e7797623d3b89d4a863f583c973c681f39aa48eadceb4aa61c0c99cc7fa19 sha256: e0ca318004c3f32144db8e74fa612abb80ebe79004677293af74ed9af119f47f
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.3" version: "2.4.4"
pdfrx_engine: pdfrx_engine:
dependency: transitive dependency: transitive
description: description:
name: pdfrx_engine name: pdfrx_engine
sha256: "69a2448ca87d0536fe4eb78fbe753fb2fb2f8ae3a1b73e2fd1937585e717c2b1" sha256: "89865e158ced818690ab207a13a34a65803cd67ee1bec9f5f87838eb5a79600b"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.1.12" version: "0.4.3"
permission_handler: permission_handler:
dependency: "direct main" dependency: "direct main"
description: description:
name: permission_handler name: permission_handler
sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1 sha256: fe54465bcc62a4564c6e4db337bbaded6c0c0fa6e10487414436d163114784f6
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "12.0.1" version: "12.0.3"
permission_handler_android: permission_handler_android:
dependency: transitive dependency: transitive
description: description:
@@ -460,10 +508,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: permission_handler_apple name: permission_handler_apple
sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 sha256: "79dfa1df734798aa3cfdad166d3a3698c206d8813de13516ea1071b5d7e2f420"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "9.4.7" version: "9.4.10"
permission_handler_html: permission_handler_html:
dependency: transitive dependency: transitive
description: description:
@@ -492,10 +540,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: petitparser name: petitparser
sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646" sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.0" version: "7.0.2"
platform: platform:
dependency: transitive dependency: transitive
description: description:
@@ -516,10 +564,26 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: posix name: posix
sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.0.3" version: "6.5.0"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
record_use:
dependency: transitive
description:
name: record_use
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
url: "https://pub.dev"
source: hosted
version: "0.6.0"
rxdart: rxdart:
dependency: transitive dependency: transitive
description: description:
@@ -532,42 +596,42 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: screen_retriever name: screen_retriever
sha256: "570dbc8e4f70bac451e0efc9c9bb19fa2d6799a11e6ef04f946d7886d2e23d0c" sha256: "42cc3b402a0f67d2455a0d067553d0f13453f6a008d98eababf8b63958d506bd"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.2.0" version: "0.2.1"
screen_retriever_linux: screen_retriever_linux:
dependency: transitive dependency: transitive
description: description:
name: screen_retriever_linux name: screen_retriever_linux
sha256: f7f8120c92ef0784e58491ab664d01efda79a922b025ff286e29aa123ea3dd18 sha256: "2a476f1a5538065bc5badf376cfdc83d6ecf07d77eb2391b9c2bff5a76970048"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.2.0" version: "0.2.1"
screen_retriever_macos: screen_retriever_macos:
dependency: transitive dependency: transitive
description: description:
name: screen_retriever_macos name: screen_retriever_macos
sha256: "71f956e65c97315dd661d71f828708bd97b6d358e776f1a30d5aa7d22d78a149" sha256: b5abb900fcb86614ff10b738b34e37b9e1d03b0447280668e2bc8a98bdc7bd59
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.2.0" version: "0.2.1"
screen_retriever_platform_interface: screen_retriever_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: screen_retriever_platform_interface name: screen_retriever_platform_interface
sha256: ee197f4581ff0d5608587819af40490748e1e39e648d7680ecf95c05197240c0 sha256: "3af22d926bedf20c2caa308eea376776451a3af125919ce072e56525fded8901"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.2.0" version: "0.2.1"
screen_retriever_windows: screen_retriever_windows:
dependency: transitive dependency: transitive
description: description:
name: screen_retriever_windows name: screen_retriever_windows
sha256: "449ee257f03ca98a57288ee526a301a430a344a161f9202b4fcc38576716fe13" sha256: c44b38a4c4bab34af259180a70a4eee1e29384e7b82e627c9faa68afcdab2e73
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.2.0" version: "0.2.1"
sky_engine: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter
@@ -577,58 +641,50 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: source_span name: source_span
sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.10.1" version: "1.10.2"
sprintf:
dependency: transitive
description:
name: sprintf
sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23"
url: "https://pub.dev"
source: hosted
version: "7.0.0"
sqflite: sqflite:
dependency: transitive dependency: transitive
description: description:
name: sqflite name: sqflite
sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03 sha256: "58a799e6ac17dd32fbab93813d39ed835a75ccc0f8f85b8955fe318c6712b082"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.2" version: "2.4.3"
sqflite_android: sqflite_android:
dependency: transitive dependency: transitive
description: description:
name: sqflite_android name: sqflite_android
sha256: "2b3070c5fa881839f8b402ee4a39c1b4d561704d4ebbbcfb808a119bc2a1701b" sha256: d0548f9d7422a2dae99ec6f8b0a3074463b132d216fa5ba0d230eeefc901983b
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.1" version: "2.4.3"
sqflite_common: sqflite_common:
dependency: transitive dependency: transitive
description: description:
name: sqflite_common name: sqflite_common
sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6" sha256: "5bf6a55c166e73bf651ba7ec3ed486e577620e3dc8f3a9c6a258a8031b624590"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.5.6" version: "2.5.11"
sqflite_darwin: sqflite_darwin:
dependency: transitive dependency: transitive
description: description:
name: sqflite_darwin name: sqflite_darwin
sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3" sha256: c86ca18b8f666bbf903924687fe21cc16fc385d086005067e26619ca530bef9f
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.2" version: "2.4.3+1"
sqflite_platform_interface: sqflite_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: sqflite_platform_interface name: sqflite_platform_interface
sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" sha256: f84939f84350d92d04416f8bc4dc52d3896aec7716cc9e80cf0146342139dc50
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.0" version: "2.4.1"
stack_trace: stack_trace:
dependency: transitive dependency: transitive
description: description:
@@ -657,10 +713,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: synchronized name: synchronized
sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 sha256: "93b153dcb6a26dcddee6ca087dd634b53e38c10b5aa163e8e49501a776456153"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.4.0" version: "3.4.1"
term_glyph: term_glyph:
dependency: transitive dependency: transitive
description: description:
@@ -673,10 +729,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.4" version: "0.7.11"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
@@ -697,34 +753,34 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_android name: url_launcher_android
sha256: "8582d7f6fe14d2652b4c45c9b6c14c0b678c2af2d083a11b604caeba51930d79" sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.3.16" version: "6.3.32"
url_launcher_ios: url_launcher_ios:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_ios name: url_launcher_ios
sha256: "7f2022359d4c099eea7df3fdf739f7d3d3b9faf3166fb1dd390775176e0b76cb" sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.3.3" version: "6.4.1"
url_launcher_linux: url_launcher_linux:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_linux name: url_launcher_linux
sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.2.1" version: "3.2.2"
url_launcher_macos: url_launcher_macos:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_macos name: url_launcher_macos
sha256: "17ba2000b847f334f16626a574c702b196723af2a289e7a93ffcb79acff855c2" sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.2.2" version: "3.2.5"
url_launcher_platform_interface: url_launcher_platform_interface:
dependency: transitive dependency: transitive
description: description:
@@ -737,42 +793,42 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_web name: url_launcher_web
sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.1" version: "2.4.3"
url_launcher_windows: url_launcher_windows:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_windows name: url_launcher_windows
sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.4" version: "3.1.5"
uuid: uuid:
dependency: transitive dependency: transitive
description: description:
name: uuid name: uuid
sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.5.1" version: "4.5.3"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:
name: vector_math name: vector_math
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.4" version: "2.2.0"
vm_service: vm_service:
dependency: transitive dependency: transitive
description: description:
name: vm_service name: vm_service
sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "15.0.0" version: "15.2.0"
web: web:
dependency: transitive dependency: transitive
description: description:
@@ -785,10 +841,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: win32 name: win32
sha256: "66814138c3562338d05613a6e368ed8cfb237ad6d64a9e9334be3f309acfca03" sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.14.0" version: "6.3.0"
window_manager: window_manager:
dependency: transitive dependency: transitive
description: description:
@@ -809,10 +865,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: xml name: xml
sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.5.0" version: "7.0.1"
yaml: yaml:
dependency: transitive dependency: transitive
description: description:
@@ -822,5 +878,5 @@ packages:
source: hosted source: hosted
version: "3.1.3" version: "3.1.3"
sdks: sdks:
dart: ">=3.8.1 <4.0.0" dart: ">=3.12.0 <4.0.0"
flutter: ">=3.29.0" flutter: ">=3.44.0"
+4 -5
View File
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 0.1.2 version: 0.2.1+2
environment: environment:
sdk: ^3.0.0 sdk: ^3.0.0
@@ -41,15 +41,14 @@ dependencies:
http: ^1.2.2 http: ^1.2.2
path_provider: ^2.1.5 path_provider: ^2.1.5
flutter_cache_manager: ^3.4.1 flutter_cache_manager: ^3.4.1
flutter_secure_storage: ^9.2.2 flutter_secure_storage: ^10.0.0
jwt_decoder: ^2.0.1 jwt_decoder: ^2.0.1
pdfrx: ^2.0.4 pdfrx: ^2.4.4
logging: ^1.3.0 logging: ^1.3.0
flutter_drawing_board: ^0.9.8
flutter_launcher_icons: ^0.14.4 flutter_launcher_icons: ^0.14.4
hive: ^2.2.3 hive: ^2.2.3
flutter_fullscreen: ^1.2.0 flutter_fullscreen: ^1.2.0
package_info_plus: ^9.0.0 package_info_plus: ^10.2.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
+2 -3
View File
@@ -7,13 +7,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:sheetless/app.dart';
import 'package:sheetless/main.dart';
void main() { void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async { testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame. // Build our app and trigger a frame.
await tester.pumpWidget(const MyApp()); await tester.pumpWidget(const SheetlessApp());
// Verify that our counter starts at 0. // Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget); expect(find.text('0'), findsOneWidget);
+1 -1
View File
@@ -11,7 +11,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST
pdfrx jni
) )
set(PLUGIN_BUNDLED_LIBRARIES) set(PLUGIN_BUNDLED_LIBRARIES)