Compare commits
46
Commits
d94e9eeb3d
...
v0.2.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52eb2fbab1 | ||
|
|
416979ab3d | ||
|
|
984e32cd03 | ||
|
|
125346668a | ||
|
|
38eb1cccc5 | ||
|
|
46c88df707 | ||
|
|
c8f941a7e0 | ||
|
|
4d1dda0263 | ||
|
|
1c5df097c4 | ||
|
|
73336ce3fa | ||
|
|
baf20e1ca8 | ||
|
|
e3ee24e06b | ||
|
|
2039080a92 | ||
|
|
a3069f0864 | ||
|
|
c468321637 | ||
|
|
22aa22ce76 | ||
|
|
d7d0668e29 | ||
|
|
76edfc5721 | ||
|
|
99a79d8df9 | ||
|
|
3affa455d0 | ||
|
|
961a8d3918 | ||
|
|
761372fb03 | ||
|
|
3f35c670ca | ||
|
|
a32bfd5108 | ||
|
|
9261ec341e | ||
|
|
7ec7b20e74 | ||
|
|
829bf7512a | ||
|
|
4fe8896f9e | ||
|
|
d3addc7973 | ||
|
|
f1b1ccf6be | ||
|
|
99300478d5 | ||
|
|
f6585e9850 | ||
|
|
16589be409 | ||
|
|
6669e2446c | ||
|
|
b62ed98375 | ||
|
|
a70c634d35 | ||
|
|
0fdf56c084 | ||
|
|
a57831f50b | ||
|
|
8f05e9244a | ||
|
|
d01e1384d4 | ||
|
|
d0fd96a2f5 | ||
|
|
58157a2e6e | ||
|
|
9a11e42571 | ||
|
|
e5c71c9261 | ||
|
|
4fd287181b | ||
|
|
181e853790 |
@@ -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 }}
|
||||||
@@ -56,3 +56,4 @@ devenv.local.nix
|
|||||||
.android
|
.android
|
||||||
android/app/.cxx/
|
android/app/.cxx/
|
||||||
/.gradle/
|
/.gradle/
|
||||||
|
/.gitea/workflows/.gradle/
|
||||||
|
|||||||
@@ -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
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -3,10 +3,11 @@
|
|||||||
"devenv": {
|
"devenv": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"dir": "src/modules",
|
"dir": "src/modules",
|
||||||
"lastModified": 1770167382,
|
"lastModified": 1783049965,
|
||||||
|
"narHash": "sha256-z2QvrvE+gzLnpqfLpQA4aq2gAB4xn6C5q2o1l7GBMXU=",
|
||||||
"owner": "cachix",
|
"owner": "cachix",
|
||||||
"repo": "devenv",
|
"repo": "devenv",
|
||||||
"rev": "195375e3f0a3c3ac4f12b21d3f59a7ddb209bd1f",
|
"rev": "afed7bf3240c70d2827569568d2c46c25a64db51",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -16,68 +17,13 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"flake-compat": {
|
|
||||||
"flake": false,
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1767039857,
|
|
||||||
"owner": "NixOS",
|
|
||||||
"repo": "flake-compat",
|
|
||||||
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "NixOS",
|
|
||||||
"repo": "flake-compat",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"git-hooks": {
|
|
||||||
"inputs": {
|
|
||||||
"flake-compat": "flake-compat",
|
|
||||||
"gitignore": "gitignore",
|
|
||||||
"nixpkgs": [
|
|
||||||
"nixpkgs"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1769939035,
|
|
||||||
"owner": "cachix",
|
|
||||||
"repo": "git-hooks.nix",
|
|
||||||
"rev": "a8ca480175326551d6c4121498316261cbb5b260",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "cachix",
|
|
||||||
"repo": "git-hooks.nix",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"gitignore": {
|
|
||||||
"inputs": {
|
|
||||||
"nixpkgs": [
|
|
||||||
"git-hooks",
|
|
||||||
"nixpkgs"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1762808025,
|
|
||||||
"owner": "hercules-ci",
|
|
||||||
"repo": "gitignore.nix",
|
|
||||||
"rev": "cb5e3fdca1de58ccbc3ef53de65bd372b48f567c",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "hercules-ci",
|
|
||||||
"repo": "gitignore.nix",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1770115704,
|
"lastModified": 1782723713,
|
||||||
|
"narHash": "sha256-oPXCU/SSUokcGaJREHibG1CBX3+s/W7orDWQOZDsEeQ=",
|
||||||
"owner": "nixos",
|
"owner": "nixos",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "e6eae2ee2110f3d31110d5c222cd395303343b08",
|
"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
@@ -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
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,23 +13,35 @@ enum ChangeType {
|
|||||||
/// Represents a single pending change to be synced with the server.
|
/// Represents a single pending change to be synced with the server.
|
||||||
///
|
///
|
||||||
/// Changes are stored locally when offline and applied once
|
/// Changes are stored locally when offline and applied once
|
||||||
/// the device regains connectivity.
|
/// the device regains connectivity. Each change has a [createdAt] timestamp
|
||||||
|
/// that the server uses to resolve conflicts between devices.
|
||||||
class Change {
|
class Change {
|
||||||
final ChangeType type;
|
final ChangeType type;
|
||||||
final String sheetUuid;
|
final String sheetUuid;
|
||||||
final String value;
|
final String value;
|
||||||
|
final DateTime createdAt;
|
||||||
|
|
||||||
Change({
|
Change({
|
||||||
required this.type,
|
required this.type,
|
||||||
required this.sheetUuid,
|
required this.sheetUuid,
|
||||||
required this.value,
|
required this.value,
|
||||||
});
|
DateTime? createdAt,
|
||||||
|
}) : createdAt = createdAt ?? DateTime.now();
|
||||||
|
|
||||||
/// Serializes this change to a map for storage.
|
/// Serializes this change to a map for storage.
|
||||||
Map<String, dynamic> toMap() => {
|
Map<String, dynamic> toMap() => {
|
||||||
'type': type.index,
|
'type': type.index,
|
||||||
'sheetUuid': sheetUuid,
|
'sheetUuid': sheetUuid,
|
||||||
'value': value,
|
'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.
|
/// Deserializes a change from a stored map.
|
||||||
@@ -41,6 +53,9 @@ class Change {
|
|||||||
type: ChangeType.values[map['type']],
|
type: ChangeType.values[map['type']],
|
||||||
sheetUuid: map['sheetUuid'],
|
sheetUuid: map['sheetUuid'],
|
||||||
value: map['value'],
|
value: map['value'],
|
||||||
|
createdAt: map['createdAt'] != null
|
||||||
|
? DateTime.parse(map['createdAt'])
|
||||||
|
: DateTime.now(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -76,16 +91,15 @@ class ChangeQueue {
|
|||||||
for (final change in _queue) {
|
for (final change in _queue) {
|
||||||
final sheet = sheets.firstWhere(
|
final sheet = sheets.firstWhere(
|
||||||
(s) => s.uuid == change.sheetUuid,
|
(s) => s.uuid == change.sheetUuid,
|
||||||
orElse: () => throw StateError(
|
orElse: () =>
|
||||||
'Sheet with UUID ${change.sheetUuid} not found',
|
throw StateError('Sheet with UUID ${change.sheetUuid} not found'),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
switch (change.type) {
|
switch (change.type) {
|
||||||
case ChangeType.sheetNameChange:
|
case ChangeType.sheetNameChange:
|
||||||
sheet.name = change.value;
|
sheet.name = change.value;
|
||||||
case ChangeType.composerNameChange:
|
case ChangeType.composerNameChange:
|
||||||
sheet.composerName = change.value;
|
sheet.composer = change.value;
|
||||||
case ChangeType.addTagChange:
|
case ChangeType.addTagChange:
|
||||||
throw UnimplementedError('Tag support not yet implemented');
|
throw UnimplementedError('Tag support not yet implemented');
|
||||||
case ChangeType.removeTagChange:
|
case ChangeType.removeTagChange:
|
||||||
|
|||||||
@@ -5,26 +5,24 @@
|
|||||||
class Sheet {
|
class Sheet {
|
||||||
final String uuid;
|
final String uuid;
|
||||||
String name;
|
String name;
|
||||||
String composerUuid;
|
String composer;
|
||||||
String composerName;
|
|
||||||
DateTime updatedAt;
|
DateTime updatedAt;
|
||||||
|
bool availableOffline;
|
||||||
|
|
||||||
Sheet({
|
Sheet({
|
||||||
required this.uuid,
|
required this.uuid,
|
||||||
required this.name,
|
required this.name,
|
||||||
required this.composerUuid,
|
required this.composer,
|
||||||
required this.composerName,
|
|
||||||
required this.updatedAt,
|
required this.updatedAt,
|
||||||
|
this.availableOffline = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Creates a [Sheet] from a JSON map returned by the API.
|
/// Creates a [Sheet] from a JSON map returned by the API.
|
||||||
factory Sheet.fromJson(Map<String, dynamic> json) {
|
factory Sheet.fromJson(Map<String, dynamic> json) {
|
||||||
final composer = json['composer'] as Map<String, dynamic>?;
|
|
||||||
return Sheet(
|
return Sheet(
|
||||||
uuid: json['uuid'].toString(),
|
uuid: json['uuid'],
|
||||||
name: json['title'],
|
name: json['title'],
|
||||||
composerUuid: json['composer_uuid']?.toString() ?? '',
|
composer: json['composer'],
|
||||||
composerName: composer?['name'] ?? 'Unknown',
|
|
||||||
updatedAt: DateTime.parse(json['updated_at']),
|
updatedAt: DateTime.parse(json['updated_at']),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -33,8 +31,7 @@ class Sheet {
|
|||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
'uuid': uuid,
|
'uuid': uuid,
|
||||||
'title': name,
|
'title': name,
|
||||||
'composer_uuid': composerUuid,
|
'composer': composer,
|
||||||
'composer_name': composerName,
|
|
||||||
'updated_at': updatedAt.toIso8601String(),
|
'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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,42 +6,34 @@ import 'package:http/http.dart' as http;
|
|||||||
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 '../models/change.dart';
|
||||||
import '../models/sheet.dart';
|
import '../models/sheet.dart';
|
||||||
|
|
||||||
/// HTTP client for communicating with the Sheetless API server.
|
/// HTTP client for communicating with the Sheetless API server.
|
||||||
///
|
|
||||||
/// Handles authentication, sheet listing, and PDF downloads.
|
|
||||||
/// Provides caching for PDF files on native platforms.
|
|
||||||
class ApiClient {
|
class ApiClient {
|
||||||
final _log = Logger('ApiClient');
|
final _log = Logger('ApiClient');
|
||||||
|
|
||||||
final String baseUrl;
|
final String baseUrl;
|
||||||
String? token;
|
String? accessToken; // Short-lived jwt
|
||||||
|
String? refreshToken; // Long lived binary token
|
||||||
|
|
||||||
ApiClient({required this.baseUrl, this.token});
|
ApiClient({required this.baseUrl, this.refreshToken});
|
||||||
|
|
||||||
/// Whether the client has an authentication token set.
|
|
||||||
bool get isAuthenticated => token != null;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Authentication
|
// Authentication
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Authenticates with the server and stores the JWT token.
|
|
||||||
///
|
|
||||||
/// Throws an [Exception] if login fails.
|
|
||||||
Future<void> login(String username, String password) async {
|
Future<void> login(String username, String password) async {
|
||||||
_log.info('Logging in...');
|
_log.info('Logging in...');
|
||||||
final url = Uri.parse('$baseUrl/auth/login');
|
|
||||||
|
|
||||||
final response = await http.post(
|
final response = await post("/auth/login", {
|
||||||
url,
|
'username': username,
|
||||||
headers: {'Content-Type': 'application/json'},
|
'password': password,
|
||||||
body: jsonEncode({'username': username, 'password': password}),
|
}, authenticateWithAccessToken: false);
|
||||||
);
|
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
final responseData = jsonDecode(response.body);
|
final responseData = jsonDecode(response.body);
|
||||||
token = responseData['token'];
|
accessToken = responseData['access_token'];
|
||||||
|
refreshToken = responseData['refresh_token'];
|
||||||
_log.info('Login successful');
|
_log.info('Login successful');
|
||||||
} else {
|
} else {
|
||||||
throw ApiException(
|
throw ApiException(
|
||||||
@@ -52,9 +44,36 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clears the authentication token.
|
Future<void> refreshAccessToken() async {
|
||||||
void logout() {
|
_log.info('Refreshing access token...');
|
||||||
token = null;
|
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');
|
_log.info('Logged out successfully');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,21 +81,43 @@ class ApiClient {
|
|||||||
// HTTP Helpers
|
// HTTP Helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
Map<String, String> _buildHeaders({bool isBinary = false}) {
|
Map<String, String> _buildHeaders({
|
||||||
return {
|
bool authenticateWithAccessToken = false,
|
||||||
'Authorization': 'Bearer $token',
|
bool isBinary = false,
|
||||||
|
}) {
|
||||||
|
final headers = {
|
||||||
|
if (authenticateWithAccessToken) 'Authorization': 'Bearer $accessToken',
|
||||||
if (!isBinary) 'Content-Type': 'application/json',
|
if (!isBinary) 'Content-Type': 'application/json',
|
||||||
};
|
};
|
||||||
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Performs a GET request to the given endpoint.
|
/// Performs a GET request to the given endpoint.
|
||||||
Future<http.Response> get(
|
Future<http.Response> get(
|
||||||
String endpoint, {
|
String endpoint, {
|
||||||
bool isBinary = false,
|
bool isBinary = false,
|
||||||
|
bool authenticateWithAccessToken = true,
|
||||||
|
bool isRetry = false,
|
||||||
}) async {
|
}) async {
|
||||||
final url = Uri.parse('$baseUrl$endpoint');
|
final url = Uri.parse('$baseUrl$endpoint');
|
||||||
final response =
|
final response = await http.get(
|
||||||
await http.get(url, headers: _buildHeaders(isBinary: isBinary));
|
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) {
|
if (response.statusCode != 200) {
|
||||||
_log.warning(
|
_log.warning(
|
||||||
@@ -95,16 +136,31 @@ class ApiClient {
|
|||||||
/// Performs a POST request with JSON body.
|
/// Performs a POST request with JSON body.
|
||||||
Future<http.Response> post(
|
Future<http.Response> post(
|
||||||
String endpoint,
|
String endpoint,
|
||||||
Map<String, dynamic> body,
|
Map<String, dynamic> body, {
|
||||||
) async {
|
bool authenticateWithAccessToken = true,
|
||||||
|
bool isRetry = false,
|
||||||
|
}) async {
|
||||||
final url = Uri.parse('$baseUrl$endpoint');
|
final url = Uri.parse('$baseUrl$endpoint');
|
||||||
|
|
||||||
final response = await http.post(
|
final response = await http.post(
|
||||||
url,
|
url,
|
||||||
headers: _buildHeaders(),
|
headers: _buildHeaders(
|
||||||
|
authenticateWithAccessToken: authenticateWithAccessToken,
|
||||||
|
),
|
||||||
body: jsonEncode(body),
|
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) {
|
if (response.statusCode != 200 && response.statusCode != 201) {
|
||||||
_log.warning(
|
_log.warning(
|
||||||
"POST '$endpoint' failed: ${response.statusCode}\n${response.body}",
|
"POST '$endpoint' failed: ${response.statusCode}\n${response.body}",
|
||||||
@@ -120,18 +176,28 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Performs a POST request with form-encoded body.
|
/// Performs a POST request with form-encoded body.
|
||||||
Future<http.Response> postFormData(String endpoint, String body) async {
|
Future<http.Response> postFormData(
|
||||||
|
String endpoint,
|
||||||
|
String body, {
|
||||||
|
bool isRetry = false,
|
||||||
|
}) async {
|
||||||
final url = Uri.parse('$baseUrl$endpoint');
|
final url = Uri.parse('$baseUrl$endpoint');
|
||||||
|
|
||||||
final response = await http.post(
|
final response = await http.post(
|
||||||
url,
|
url,
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': 'Bearer $token',
|
'Authorization': 'Bearer $accessToken',
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
},
|
},
|
||||||
body: body,
|
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) {
|
if (response.statusCode != 200 && response.statusCode != 201) {
|
||||||
_log.warning(
|
_log.warning(
|
||||||
"POST Form '$endpoint' failed: ${response.statusCode}\n${response.body}",
|
"POST Form '$endpoint' failed: ${response.statusCode}\n${response.body}",
|
||||||
@@ -187,6 +253,107 @@ class ApiClient {
|
|||||||
_log.info('PDF cached at: ${cachedFile.path}');
|
_log.info('PDF cached at: ${cachedFile.path}');
|
||||||
return cachedFile;
|
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.
|
/// Exception thrown when an API request fails.
|
||||||
|
|||||||
@@ -1,10 +1,72 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
import 'package:hive/hive.dart';
|
import 'package:hive/hive.dart';
|
||||||
import 'package:sheetless/core/models/change.dart';
|
import 'package:sheetless/core/models/change.dart';
|
||||||
import 'package:sheetless/core/models/config.dart';
|
import 'package:sheetless/core/models/config.dart';
|
||||||
|
import 'package:sheetless/core/models/sheet.dart';
|
||||||
|
|
||||||
/// Keys for secure storage (credentials and tokens).
|
/// Keys for secure storage (credentials and tokens).
|
||||||
enum SecureStorageKey { url, jwt, email }
|
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.
|
/// Service for managing local storage operations.
|
||||||
///
|
///
|
||||||
@@ -17,13 +79,13 @@ class StorageService {
|
|||||||
static const String _configBox = 'config';
|
static const String _configBox = 'config';
|
||||||
static const String _changeQueueBox = 'changeQueue';
|
static const String _changeQueueBox = 'changeQueue';
|
||||||
static const String _annotationsBox = 'annotations';
|
static const String _annotationsBox = 'annotations';
|
||||||
|
static const String _sheetsBox = 'sheets';
|
||||||
|
static const String _pendingAnnotationsBox = 'pendingAnnotations';
|
||||||
|
|
||||||
late final FlutterSecureStorage _secureStorage;
|
late final FlutterSecureStorage _secureStorage;
|
||||||
|
|
||||||
StorageService() {
|
StorageService() {
|
||||||
_secureStorage = FlutterSecureStorage(
|
_secureStorage = FlutterSecureStorage();
|
||||||
aOptions: const AndroidOptions(encryptedSharedPreferences: true),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -42,9 +104,33 @@ class StorageService {
|
|||||||
return _secureStorage.write(key: key.name, value: value);
|
return _secureStorage.write(key: key.name, value: value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clears the JWT token from secure storage.
|
/// Clears all user data except URL and email.
|
||||||
Future<void> clearToken() {
|
///
|
||||||
return writeSecure(SecureStorageKey.jwt, null);
|
/// 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -77,8 +163,7 @@ class StorageService {
|
|||||||
Future<Map<String, DateTime>> readSheetAccessTimes() async {
|
Future<Map<String, DateTime>> readSheetAccessTimes() async {
|
||||||
final box = await Hive.openBox(_sheetAccessTimesBox);
|
final box = await Hive.openBox(_sheetAccessTimesBox);
|
||||||
return box.toMap().map(
|
return box.toMap().map(
|
||||||
(key, value) =>
|
(key, value) => MapEntry(key as String, DateTime.parse(value as String)),
|
||||||
MapEntry(key as String, DateTime.parse(value as String)),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,16 +219,26 @@ class StorageService {
|
|||||||
/// Returns the JSON string of annotations, or null if none exist.
|
/// Returns the JSON string of annotations, or null if none exist.
|
||||||
Future<String?> readAnnotations(String sheetUuid, int pageNumber) async {
|
Future<String?> readAnnotations(String sheetUuid, int pageNumber) async {
|
||||||
final box = await Hive.openBox(_annotationsBox);
|
final box = await Hive.openBox(_annotationsBox);
|
||||||
return box.get(_annotationKey(sheetUuid, pageNumber));
|
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 for a specific sheet page.
|
/// Writes annotations with a specific lastModified timestamp.
|
||||||
///
|
///
|
||||||
/// Pass null or empty string to delete annotations for that page.
|
/// Used when syncing from server to preserve server's timestamp.
|
||||||
Future<void> writeAnnotations(
|
Future<void> writeAnnotationsWithMetadata(
|
||||||
String sheetUuid,
|
String sheetUuid,
|
||||||
int pageNumber,
|
int pageNumber,
|
||||||
String? annotationsJson,
|
String? annotationsJson,
|
||||||
|
DateTime lastModified,
|
||||||
) async {
|
) async {
|
||||||
final box = await Hive.openBox(_annotationsBox);
|
final box = await Hive.openBox(_annotationsBox);
|
||||||
final key = _annotationKey(sheetUuid, pageNumber);
|
final key = _annotationKey(sheetUuid, pageNumber);
|
||||||
@@ -151,7 +246,11 @@ class StorageService {
|
|||||||
if (annotationsJson == null || annotationsJson.isEmpty) {
|
if (annotationsJson == null || annotationsJson.isEmpty) {
|
||||||
await box.delete(key);
|
await box.delete(key);
|
||||||
} else {
|
} else {
|
||||||
await box.put(key, annotationsJson);
|
final stored = StoredAnnotation(
|
||||||
|
annotationsJson: annotationsJson,
|
||||||
|
lastModified: lastModified,
|
||||||
|
);
|
||||||
|
await box.put(key, stored.toMap());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,8 +268,54 @@ class StorageService {
|
|||||||
final pageNumber = int.tryParse(pageStr);
|
final pageNumber = int.tryParse(pageStr);
|
||||||
if (pageNumber != null) {
|
if (pageNumber != null) {
|
||||||
final value = box.get(key);
|
final value = box.get(key);
|
||||||
if (value != null && value is String && value.isNotEmpty) {
|
if (value != null) {
|
||||||
|
// Handle legacy format (plain string) and new format (map)
|
||||||
|
if (value is String && value.isNotEmpty) {
|
||||||
result[pageNumber] = value;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -183,11 +328,103 @@ class StorageService {
|
|||||||
Future<void> deleteAllAnnotations(String sheetUuid) async {
|
Future<void> deleteAllAnnotations(String sheetUuid) async {
|
||||||
final box = await Hive.openBox(_annotationsBox);
|
final box = await Hive.openBox(_annotationsBox);
|
||||||
final prefix = '${sheetUuid}_page_';
|
final prefix = '${sheetUuid}_page_';
|
||||||
final keysToDelete =
|
final keysToDelete = box.keys.where(
|
||||||
box.keys.where((key) => key is String && key.startsWith(prefix));
|
(key) => key is String && key.startsWith(prefix),
|
||||||
|
);
|
||||||
|
|
||||||
for (final key in keysToDelete.toList()) {
|
for (final key in keysToDelete.toList()) {
|
||||||
await box.delete(key);
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,9 +48,12 @@ class _LoginPageState extends State<LoginPage> {
|
|||||||
|
|
||||||
/// Attempts to auto-login using a stored JWT token.
|
/// Attempts to auto-login using a stored JWT token.
|
||||||
Future<void> _tryAutoLogin() async {
|
Future<void> _tryAutoLogin() async {
|
||||||
final jwt = await _storageService.readSecure(SecureStorageKey.jwt);
|
final refreshToken = await _storageService.readSecure(
|
||||||
|
SecureStorageKey.refreshToken,
|
||||||
|
);
|
||||||
|
|
||||||
if (jwt != null && _isTokenValid(jwt)) {
|
if (refreshToken != null) {
|
||||||
|
// TODO: this assumes the refresh token is always valid
|
||||||
await _navigateToHome();
|
await _navigateToHome();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -71,7 +74,9 @@ class _LoginPageState extends State<LoginPage> {
|
|||||||
/// Restores previously saved URL and username for convenience.
|
/// Restores previously saved URL and username for convenience.
|
||||||
Future<void> _restoreCredentials() async {
|
Future<void> _restoreCredentials() async {
|
||||||
final url = await _storageService.readSecure(SecureStorageKey.url);
|
final url = await _storageService.readSecure(SecureStorageKey.url);
|
||||||
final username = await _storageService.readSecure(SecureStorageKey.email);
|
final username = await _storageService.readSecure(
|
||||||
|
SecureStorageKey.username,
|
||||||
|
);
|
||||||
|
|
||||||
if (url != null) _urlController.text = url;
|
if (url != null) _urlController.text = url;
|
||||||
if (username != null) _usernameController.text = username;
|
if (username != null) _usernameController.text = username;
|
||||||
@@ -96,8 +101,15 @@ class _LoginPageState extends State<LoginPage> {
|
|||||||
await apiClient.login(_usernameController.text, _passwordController.text);
|
await apiClient.login(_usernameController.text, _passwordController.text);
|
||||||
|
|
||||||
// Save credentials for next time
|
// Save credentials for next time
|
||||||
await _saveCredentials(apiClient.token!);
|
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();
|
await _navigateToHome();
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_log.warning('Login failed', e);
|
_log.warning('Login failed', e);
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -111,14 +123,17 @@ class _LoginPageState extends State<LoginPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Saves credentials after successful login.
|
/// Saves credentials after successful login.
|
||||||
Future<void> _saveCredentials(String token) async {
|
Future<void> _saveCredentials(String refreshToken) async {
|
||||||
await _storageService.writeSecure(
|
await _storageService.writeSecure(
|
||||||
SecureStorageKey.url,
|
SecureStorageKey.url,
|
||||||
_urlController.text,
|
_urlController.text,
|
||||||
);
|
);
|
||||||
await _storageService.writeSecure(SecureStorageKey.jwt, token);
|
|
||||||
await _storageService.writeSecure(
|
await _storageService.writeSecure(
|
||||||
SecureStorageKey.email,
|
SecureStorageKey.refreshToken,
|
||||||
|
refreshToken,
|
||||||
|
);
|
||||||
|
await _storageService.writeSecure(
|
||||||
|
SecureStorageKey.username,
|
||||||
_usernameController.text,
|
_usernameController.text,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_fullscreen/flutter_fullscreen.dart';
|
import 'package:flutter_fullscreen/flutter_fullscreen.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
@@ -6,6 +7,7 @@ import 'package:sheetless/core/models/config.dart';
|
|||||||
import 'package:sheetless/core/models/sheet.dart';
|
import 'package:sheetless/core/models/sheet.dart';
|
||||||
import 'package:sheetless/core/services/api_client.dart';
|
import 'package:sheetless/core/services/api_client.dart';
|
||||||
import 'package:sheetless/core/services/storage_service.dart';
|
import 'package:sheetless/core/services/storage_service.dart';
|
||||||
|
import 'package:sheetless/core/services/sync_service.dart';
|
||||||
|
|
||||||
import '../../app.dart';
|
import '../../app.dart';
|
||||||
import '../auth/login_page.dart';
|
import '../auth/login_page.dart';
|
||||||
@@ -34,8 +36,11 @@ class _HomePageState extends State<HomePage> with RouteAware {
|
|||||||
final _storageService = StorageService();
|
final _storageService = StorageService();
|
||||||
|
|
||||||
ApiClient? _apiClient;
|
ApiClient? _apiClient;
|
||||||
late Future<List<Sheet>> _sheetsFuture;
|
SyncService? _syncService;
|
||||||
|
late Future<SyncResult> _syncFuture;
|
||||||
|
List<Sheet> _sheets = [];
|
||||||
bool _isShuffling = false;
|
bool _isShuffling = false;
|
||||||
|
bool _isOnline = true;
|
||||||
String? _appName;
|
String? _appName;
|
||||||
String? _appVersion;
|
String? _appVersion;
|
||||||
|
|
||||||
@@ -44,7 +49,9 @@ class _HomePageState extends State<HomePage> with RouteAware {
|
|||||||
super.initState();
|
super.initState();
|
||||||
|
|
||||||
// Exit fullscreen when entering home page
|
// Exit fullscreen when entering home page
|
||||||
|
if (FullScreen.isFullScreen) {
|
||||||
FullScreen.setFullScreen(false);
|
FullScreen.setFullScreen(false);
|
||||||
|
}
|
||||||
|
|
||||||
// Subscribe to route changes
|
// Subscribe to route changes
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
@@ -52,7 +59,7 @@ class _HomePageState extends State<HomePage> with RouteAware {
|
|||||||
});
|
});
|
||||||
|
|
||||||
_loadAppInfo();
|
_loadAppInfo();
|
||||||
_sheetsFuture = _loadSheets();
|
_syncFuture = _loadSheets();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -67,14 +74,19 @@ class _HomePageState extends State<HomePage> with RouteAware {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void didPush() {
|
void didPush() {
|
||||||
|
// Exit fullscreen when entering home page
|
||||||
|
if (FullScreen.isFullScreen) {
|
||||||
FullScreen.setFullScreen(false);
|
FullScreen.setFullScreen(false);
|
||||||
|
}
|
||||||
super.didPush();
|
super.didPush();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didPopNext() {
|
void didPopNext() {
|
||||||
// Exit fullscreen when returning to home page
|
// Exit fullscreen when returning to home page
|
||||||
|
if (FullScreen.isFullScreen) {
|
||||||
FullScreen.setFullScreen(false);
|
FullScreen.setFullScreen(false);
|
||||||
|
}
|
||||||
super.didPopNext();
|
super.didPopNext();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,19 +102,39 @@ class _HomePageState extends State<HomePage> with RouteAware {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<Sheet>> _loadSheets() async {
|
Future<SyncResult> _loadSheets() async {
|
||||||
final url = await _storageService.readSecure(SecureStorageKey.url);
|
final url = await _storageService.readSecure(SecureStorageKey.url);
|
||||||
final jwt = await _storageService.readSecure(SecureStorageKey.jwt);
|
final refreshToken = await _storageService.readSecure(
|
||||||
|
SecureStorageKey.refreshToken,
|
||||||
|
);
|
||||||
|
|
||||||
_apiClient = ApiClient(baseUrl: url!, token: jwt);
|
_apiClient = ApiClient(baseUrl: url!, refreshToken: refreshToken);
|
||||||
|
_syncService = SyncService(
|
||||||
|
apiClient: _apiClient!,
|
||||||
|
storageService: _storageService,
|
||||||
|
);
|
||||||
|
|
||||||
final sheets = await _apiClient!.fetchSheets();
|
// Perform sync (fetches sheets, uploads pending changes/annotations)
|
||||||
_log.info('${sheets.length} sheets fetched');
|
final result = await _syncService!.sync();
|
||||||
|
_log.info(
|
||||||
|
'${result.sheets.length} sheets loaded (online: ${result.isOnline}, '
|
||||||
|
'changes synced: ${result.changesSynced}, '
|
||||||
|
'annotations synced: ${result.annotationsSynced})',
|
||||||
|
);
|
||||||
|
|
||||||
final sortedSheets = await _sortSheetsByRecency(sheets);
|
// Sort and store sheets
|
||||||
_log.info('${sortedSheets.length} sheets sorted');
|
_sheets = await _sortSheetsByRecency(result.sheets);
|
||||||
|
setState(() {
|
||||||
|
_isOnline = result.isOnline;
|
||||||
|
});
|
||||||
|
|
||||||
return sortedSheets;
|
if (!kIsWeb) {
|
||||||
|
for (var sheet in _sheets) {
|
||||||
|
sheet.availableOffline = await _apiClient!.isPdfFileCached(sheet.uuid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<Sheet>> _sortSheetsByRecency(List<Sheet> sheets) async {
|
Future<List<Sheet>> _sortSheetsByRecency(List<Sheet> sheets) async {
|
||||||
@@ -128,7 +160,7 @@ class _HomePageState extends State<HomePage> with RouteAware {
|
|||||||
|
|
||||||
Future<void> _refreshSheets() async {
|
Future<void> _refreshSheets() async {
|
||||||
setState(() {
|
setState(() {
|
||||||
_sheetsFuture = _loadSheets();
|
_syncFuture = _loadSheets();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,19 +169,18 @@ class _HomePageState extends State<HomePage> with RouteAware {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
void _handleShuffleChanged(bool enabled) async {
|
void _handleShuffleChanged(bool enabled) async {
|
||||||
final sheets = await _sheetsFuture;
|
|
||||||
|
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
sheets.shuffle();
|
_sheets.shuffle();
|
||||||
} else {
|
} else {
|
||||||
await _sortSheetsByRecency(sheets);
|
await _sortSheetsByRecency(_sheets);
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(() => _isShuffling = enabled);
|
setState(() => _isShuffling = enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleLogout() async {
|
Future<void> _handleLogout() async {
|
||||||
await _storageService.clearToken();
|
await _storageService.clearAllUserData();
|
||||||
|
await _apiClient!.logout();
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
@@ -181,21 +212,34 @@ class _HomePageState extends State<HomePage> with RouteAware {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Sheetless')),
|
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(
|
endDrawer: AppDrawer(
|
||||||
isShuffling: _isShuffling,
|
isShuffling: _isShuffling,
|
||||||
onShuffleChanged: _handleShuffleChanged,
|
onShuffleChanged: _handleShuffleChanged,
|
||||||
onLogout: _handleLogout,
|
onLogout: _handleLogout,
|
||||||
appName: _appName,
|
appName: _appName,
|
||||||
appVersion: _appVersion,
|
appVersion: _appVersion,
|
||||||
|
syncFuture: _syncFuture,
|
||||||
),
|
),
|
||||||
body: RefreshIndicator(onRefresh: _refreshSheets, child: _buildBody()),
|
body: RefreshIndicator(onRefresh: _refreshSheets, child: _buildBody()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildBody() {
|
Widget _buildBody() {
|
||||||
return FutureBuilder<List<Sheet>>(
|
return FutureBuilder<SyncResult>(
|
||||||
future: _sheetsFuture,
|
future: _syncFuture,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
if (snapshot.connectionState != ConnectionState.done) {
|
if (snapshot.connectionState != ConnectionState.done) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
@@ -208,8 +252,9 @@ class _HomePageState extends State<HomePage> with RouteAware {
|
|||||||
|
|
||||||
if (snapshot.hasData) {
|
if (snapshot.hasData) {
|
||||||
return SheetsList(
|
return SheetsList(
|
||||||
sheets: snapshot.data!,
|
sheets: _sheets,
|
||||||
onSheetSelected: _openSheet,
|
onSheetSelected: _openSheet,
|
||||||
|
syncService: _syncService!,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:sheetless/core/services/sync_service.dart';
|
||||||
|
|
||||||
/// Callback for shuffle state changes.
|
/// Callback for shuffle state changes.
|
||||||
typedef ShuffleCallback = void Function(bool enabled);
|
typedef ShuffleCallback = void Function(bool enabled);
|
||||||
@@ -12,12 +13,14 @@ class AppDrawer extends StatelessWidget {
|
|||||||
final VoidCallback onLogout;
|
final VoidCallback onLogout;
|
||||||
final String? appName;
|
final String? appName;
|
||||||
final String? appVersion;
|
final String? appVersion;
|
||||||
|
final Future<SyncResult> syncFuture;
|
||||||
|
|
||||||
const AppDrawer({
|
const AppDrawer({
|
||||||
super.key,
|
super.key,
|
||||||
required this.isShuffling,
|
required this.isShuffling,
|
||||||
required this.onShuffleChanged,
|
required this.onShuffleChanged,
|
||||||
required this.onLogout,
|
required this.onLogout,
|
||||||
|
required this.syncFuture,
|
||||||
this.appName,
|
this.appName,
|
||||||
this.appVersion,
|
this.appVersion,
|
||||||
});
|
});
|
||||||
@@ -32,7 +35,7 @@ class AppDrawer extends StatelessWidget {
|
|||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
_buildActions(),
|
_buildActions(),
|
||||||
_buildAppInfo(),
|
Column(children: [_buildSyncStatus(), _buildAppInfo()]),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -44,10 +47,7 @@ class AppDrawer extends StatelessWidget {
|
|||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: Icon(
|
leading: Icon(Icons.shuffle, color: isShuffling ? Colors.blue : null),
|
||||||
Icons.shuffle,
|
|
||||||
color: isShuffling ? Colors.blue : null,
|
|
||||||
),
|
|
||||||
title: const Text('Shuffle'),
|
title: const Text('Shuffle'),
|
||||||
onTap: () => onShuffleChanged(!isShuffling),
|
onTap: () => onShuffleChanged(!isShuffling),
|
||||||
),
|
),
|
||||||
@@ -60,6 +60,47 @@ class AppDrawer extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
Widget _buildAppInfo() {
|
||||||
final versionText = appName != null && appVersion != null
|
final versionText = appName != null && appVersion != null
|
||||||
? '$appName v$appVersion'
|
? '$appName v$appVersion'
|
||||||
@@ -67,10 +108,7 @@ class AppDrawer extends StatelessWidget {
|
|||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
child: Text(
|
child: Text(versionText, style: const TextStyle(color: Colors.grey)),
|
||||||
versionText,
|
|
||||||
style: const TextStyle(color: Colors.grey),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import '../../../core/models/sheet.dart';
|
import '../../../core/models/sheet.dart';
|
||||||
|
|
||||||
/// A list tile displaying a single sheet's information.
|
/// A list tile displaying a single sheet's information.
|
||||||
@@ -21,9 +22,13 @@ class SheetListItem extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ListTile(
|
return ListTile(
|
||||||
title: Text(sheet.name),
|
title: Text(sheet.name),
|
||||||
subtitle: Text(sheet.composerName),
|
subtitle: Text(sheet.composer),
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
onLongPress: onLongPress,
|
onLongPress: onLongPress,
|
||||||
|
trailing: Visibility(
|
||||||
|
visible: !kIsWeb && sheet.availableOffline,
|
||||||
|
child: Icon(Icons.download_done, color: Colors.green),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import 'dart:ui';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:sheetless/core/models/change.dart';
|
import 'package:sheetless/core/models/change.dart';
|
||||||
import 'package:sheetless/core/models/sheet.dart';
|
import 'package:sheetless/core/models/sheet.dart';
|
||||||
import 'package:sheetless/core/services/storage_service.dart';
|
import 'package:sheetless/core/services/sync_service.dart';
|
||||||
|
|
||||||
import '../../../shared/widgets/edit_sheet_bottom_sheet.dart';
|
import '../../../shared/widgets/edit_sheet_bottom_sheet.dart';
|
||||||
import 'sheet_list_item.dart';
|
import 'sheet_list_item.dart';
|
||||||
@@ -19,11 +19,13 @@ import 'sheet_search_bar.dart';
|
|||||||
class SheetsList extends StatefulWidget {
|
class SheetsList extends StatefulWidget {
|
||||||
final List<Sheet> sheets;
|
final List<Sheet> sheets;
|
||||||
final ValueSetter<Sheet> onSheetSelected;
|
final ValueSetter<Sheet> onSheetSelected;
|
||||||
|
final SyncService syncService;
|
||||||
|
|
||||||
const SheetsList({
|
const SheetsList({
|
||||||
super.key,
|
super.key,
|
||||||
required this.sheets,
|
required this.sheets,
|
||||||
required this.onSheetSelected,
|
required this.onSheetSelected,
|
||||||
|
required this.syncService,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -33,7 +35,6 @@ class SheetsList extends StatefulWidget {
|
|||||||
class _SheetsListState extends State<SheetsList> {
|
class _SheetsListState extends State<SheetsList> {
|
||||||
static const _searchDebounceMs = 500;
|
static const _searchDebounceMs = 500;
|
||||||
|
|
||||||
final _storageService = StorageService();
|
|
||||||
final _searchController = TextEditingController();
|
final _searchController = TextEditingController();
|
||||||
Timer? _debounceTimer;
|
Timer? _debounceTimer;
|
||||||
late List<Sheet> _filteredSheets;
|
late List<Sheet> _filteredSheets;
|
||||||
@@ -79,7 +80,7 @@ class _SheetsListState extends State<SheetsList> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_filteredSheets = widget.sheets.where((sheet) {
|
_filteredSheets = widget.sheets.where((sheet) {
|
||||||
final name = sheet.name.toLowerCase();
|
final name = sheet.name.toLowerCase();
|
||||||
final composer = sheet.composerName.toLowerCase();
|
final composer = sheet.composer.toLowerCase();
|
||||||
|
|
||||||
// Each term must appear in either name or composer
|
// Each term must appear in either name or composer
|
||||||
return terms.every(
|
return terms.every(
|
||||||
@@ -111,9 +112,9 @@ class _SheetsListState extends State<SheetsList> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _handleSheetEdit(Sheet sheet, String newName, String newComposer) {
|
void _handleSheetEdit(Sheet sheet, String newName, String newComposer) {
|
||||||
// Queue changes for server sync
|
// Queue changes for server sync (with timestamp for conflict resolution)
|
||||||
if (newName != sheet.name) {
|
if (newName != sheet.name) {
|
||||||
_storageService.writeChange(
|
widget.syncService.queueChange(
|
||||||
Change(
|
Change(
|
||||||
type: ChangeType.sheetNameChange,
|
type: ChangeType.sheetNameChange,
|
||||||
sheetUuid: sheet.uuid,
|
sheetUuid: sheet.uuid,
|
||||||
@@ -121,8 +122,8 @@ class _SheetsListState extends State<SheetsList> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (newComposer != sheet.composerName) {
|
if (newComposer != sheet.composer) {
|
||||||
_storageService.writeChange(
|
widget.syncService.queueChange(
|
||||||
Change(
|
Change(
|
||||||
type: ChangeType.composerNameChange,
|
type: ChangeType.composerNameChange,
|
||||||
sheetUuid: sheet.uuid,
|
sheetUuid: sheet.uuid,
|
||||||
@@ -134,8 +135,11 @@ class _SheetsListState extends State<SheetsList> {
|
|||||||
// Update local state
|
// Update local state
|
||||||
setState(() {
|
setState(() {
|
||||||
sheet.name = newName;
|
sheet.name = newName;
|
||||||
sheet.composerName = newComposer;
|
sheet.composer = newComposer;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update cached sheets
|
||||||
|
widget.syncService.updateCachedSheets(widget.sheets);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ class DrawingController extends ChangeNotifier {
|
|||||||
/// Maximum number of history steps to keep
|
/// Maximum number of history steps to keep
|
||||||
final int maxHistorySteps;
|
final int maxHistorySteps;
|
||||||
|
|
||||||
|
/// Whether there are unsaved changes since last load/clear
|
||||||
|
bool _hasUnsavedChanges = false;
|
||||||
|
|
||||||
DrawingController({this.maxHistorySteps = 50});
|
DrawingController({this.maxHistorySteps = 50});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -75,6 +78,9 @@ class DrawingController extends ChangeNotifier {
|
|||||||
/// Whether redo is available
|
/// Whether redo is available
|
||||||
bool get canRedo => _redoStack.isNotEmpty;
|
bool get canRedo => _redoStack.isNotEmpty;
|
||||||
|
|
||||||
|
/// Whether there are unsaved changes since last load/clear/markSaved
|
||||||
|
bool get hasUnsavedChanges => _hasUnsavedChanges;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Drawing Operations
|
// Drawing Operations
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -120,6 +126,7 @@ class DrawingController extends ChangeNotifier {
|
|||||||
_redoStack.clear();
|
_redoStack.clear();
|
||||||
_trimHistory();
|
_trimHistory();
|
||||||
_currentErasedLines.clear();
|
_currentErasedLines.clear();
|
||||||
|
_hasUnsavedChanges = true;
|
||||||
notifyListeners(); // Update UI to enable undo button
|
notifyListeners(); // Update UI to enable undo button
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -133,6 +140,7 @@ class DrawingController extends ChangeNotifier {
|
|||||||
_undoStack.add(AddLineAction(_currentLine!));
|
_undoStack.add(AddLineAction(_currentLine!));
|
||||||
_redoStack.clear();
|
_redoStack.clear();
|
||||||
_trimHistory();
|
_trimHistory();
|
||||||
|
_hasUnsavedChanges = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
_currentLine = null;
|
_currentLine = null;
|
||||||
@@ -257,6 +265,7 @@ class DrawingController extends ChangeNotifier {
|
|||||||
_redoStack.add(action);
|
_redoStack.add(action);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_hasUnsavedChanges = true;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,6 +288,7 @@ class DrawingController extends ChangeNotifier {
|
|||||||
_undoStack.add(action);
|
_undoStack.add(action);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_hasUnsavedChanges = true;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,6 +299,7 @@ class DrawingController extends ChangeNotifier {
|
|||||||
_redoStack.clear();
|
_redoStack.clear();
|
||||||
_currentLine = null;
|
_currentLine = null;
|
||||||
_currentErasedLines.clear();
|
_currentErasedLines.clear();
|
||||||
|
_hasUnsavedChanges = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,6 +335,7 @@ class DrawingController extends ChangeNotifier {
|
|||||||
_redoStack.clear();
|
_redoStack.clear();
|
||||||
_currentLine = null;
|
_currentLine = null;
|
||||||
_currentErasedLines.clear();
|
_currentErasedLines.clear();
|
||||||
|
_hasUnsavedChanges = false;
|
||||||
|
|
||||||
for (final json in jsonList) {
|
for (final json in jsonList) {
|
||||||
_lines.add(DrawingLine.fromJson(json));
|
_lines.add(DrawingLine.fromJson(json));
|
||||||
@@ -354,6 +366,11 @@ class DrawingController extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Marks the current state as saved (resets unsaved changes flag).
|
||||||
|
void markSaved() {
|
||||||
|
_hasUnsavedChanges = false;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_lines.clear();
|
_lines.clear();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:logging/logging.dart';
|
|||||||
import 'package:pdfrx/pdfrx.dart';
|
import 'package:pdfrx/pdfrx.dart';
|
||||||
import 'package:sheetless/core/models/config.dart';
|
import 'package:sheetless/core/models/config.dart';
|
||||||
import 'package:sheetless/core/models/sheet.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/api_client.dart';
|
||||||
import 'package:sheetless/core/services/storage_service.dart';
|
import 'package:sheetless/core/services/storage_service.dart';
|
||||||
|
|
||||||
@@ -35,9 +36,11 @@ class _SheetViewerPageState extends State<SheetViewerPage>
|
|||||||
with FullScreenListener {
|
with FullScreenListener {
|
||||||
final _log = Logger('SheetViewerPage');
|
final _log = Logger('SheetViewerPage');
|
||||||
final _storageService = StorageService();
|
final _storageService = StorageService();
|
||||||
|
late final AnnotationSyncService _syncService;
|
||||||
|
|
||||||
PdfDocument? _document;
|
PdfDocument? _document;
|
||||||
late Future<bool> _documentLoaded;
|
late Future<bool> _documentLoaded;
|
||||||
|
late Future<void> _annotationsLoaded;
|
||||||
int _currentPage = 1;
|
int _currentPage = 1;
|
||||||
int _totalPages = 1;
|
int _totalPages = 1;
|
||||||
bool _isPaintMode = false;
|
bool _isPaintMode = false;
|
||||||
@@ -52,20 +55,38 @@ class _SheetViewerPageState extends State<SheetViewerPage>
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
||||||
|
// Initialize sync service
|
||||||
|
_syncService = AnnotationSyncService(
|
||||||
|
apiClient: widget.apiClient,
|
||||||
|
storageService: _storageService,
|
||||||
|
);
|
||||||
|
|
||||||
// Initialize drawing controllers
|
// Initialize drawing controllers
|
||||||
_leftDrawingController = DrawingController(maxHistorySteps: 50);
|
_leftDrawingController = DrawingController(maxHistorySteps: 50);
|
||||||
_rightDrawingController = DrawingController(maxHistorySteps: 50);
|
_rightDrawingController = DrawingController(maxHistorySteps: 50);
|
||||||
|
|
||||||
FullScreen.addListener(this);
|
FullScreen.addListener(this);
|
||||||
|
if (FullScreen.isFullScreen != widget.config.fullscreen) {
|
||||||
FullScreen.setFullScreen(widget.config.fullscreen);
|
FullScreen.setFullScreen(widget.config.fullscreen);
|
||||||
|
}
|
||||||
_documentLoaded = _loadPdf();
|
_documentLoaded = _loadPdf();
|
||||||
|
_syncAnnotations();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _syncAnnotations() {
|
||||||
|
setState(() {
|
||||||
|
_annotationsLoaded = _syncService.syncAnnotationsFromServer(
|
||||||
|
widget.sheet.uuid,
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
// Save current annotations synchronously before disposing
|
// Make sure annotations are saved before exiting
|
||||||
// Note: This is fire-and-forget, but Hive operations are fast enough
|
if (_isPaintMode) {
|
||||||
_saveCurrentAnnotationsSync();
|
_saveCurrentAnnotations();
|
||||||
|
}
|
||||||
|
|
||||||
_leftDrawingController.dispose();
|
_leftDrawingController.dispose();
|
||||||
_rightDrawingController.dispose();
|
_rightDrawingController.dispose();
|
||||||
@@ -74,27 +95,6 @@ class _SheetViewerPageState extends State<SheetViewerPage>
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Synchronous version that doesn't await - used in dispose
|
|
||||||
void _saveCurrentAnnotationsSync() {
|
|
||||||
// Save left page (always, since paint mode is single-page only)
|
|
||||||
final leftJson = _leftDrawingController.toJsonString();
|
|
||||||
_storageService.writeAnnotations(
|
|
||||||
widget.sheet.uuid,
|
|
||||||
_currentPage,
|
|
||||||
leftJson.isEmpty || leftJson == '[]' ? null : leftJson,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Save right page if in two-page mode
|
|
||||||
if (widget.config.twoPageMode && _currentPage < _totalPages) {
|
|
||||||
final rightJson = _rightDrawingController.toJsonString();
|
|
||||||
_storageService.writeAnnotations(
|
|
||||||
widget.sheet.uuid,
|
|
||||||
_currentPage + 1,
|
|
||||||
rightJson.isEmpty || rightJson == '[]' ? null : rightJson,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// PDF Loading
|
// PDF Loading
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -107,9 +107,13 @@ class _SheetViewerPageState extends State<SheetViewerPage>
|
|||||||
} else {
|
} else {
|
||||||
// Native: use file cache
|
// Native: use file cache
|
||||||
final file = await widget.apiClient.getPdfFileCached(widget.sheet.uuid);
|
final file = await widget.apiClient.getPdfFileCached(widget.sheet.uuid);
|
||||||
|
_log.fine("Found file, opening...");
|
||||||
_document = await PdfDocument.openFile(file.path);
|
_document = await PdfDocument.openFile(file.path);
|
||||||
|
widget.sheet.availableOffline = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_log.fine("Pdf loaded");
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_totalPages = _document!.pages.length;
|
_totalPages = _document!.pages.length;
|
||||||
});
|
});
|
||||||
@@ -117,6 +121,8 @@ class _SheetViewerPageState extends State<SheetViewerPage>
|
|||||||
// Load annotations for current page(s)
|
// Load annotations for current page(s)
|
||||||
await _loadAnnotationsForCurrentPages();
|
await _loadAnnotationsForCurrentPages();
|
||||||
|
|
||||||
|
_log.fine("Annotations loaded");
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,24 +157,58 @@ class _SheetViewerPageState extends State<SheetViewerPage>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Saves the current page(s) annotations to storage.
|
/// 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 {
|
Future<void> _saveCurrentAnnotations() async {
|
||||||
// Save left page
|
final now = DateTime.now();
|
||||||
|
|
||||||
|
// Save left page only if changed
|
||||||
|
if (_leftDrawingController.hasUnsavedChanges) {
|
||||||
final leftJson = _leftDrawingController.toJsonString();
|
final leftJson = _leftDrawingController.toJsonString();
|
||||||
await _storageService.writeAnnotations(
|
final leftHasContent = leftJson.isNotEmpty && leftJson != '[]';
|
||||||
|
|
||||||
|
await _storageService.writeAnnotationsWithMetadata(
|
||||||
widget.sheet.uuid,
|
widget.sheet.uuid,
|
||||||
_currentPage,
|
_currentPage,
|
||||||
leftJson.isEmpty || leftJson == '[]' ? null : leftJson,
|
leftHasContent ? leftJson : null,
|
||||||
|
now,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Save right page (two-page mode)
|
// Upload left page to server
|
||||||
if (widget.config.twoPageMode && _currentPage < _totalPages) {
|
_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 rightJson = _rightDrawingController.toJsonString();
|
||||||
await _storageService.writeAnnotations(
|
final rightHasContent = rightJson.isNotEmpty && rightJson != '[]';
|
||||||
|
|
||||||
|
await _storageService.writeAnnotationsWithMetadata(
|
||||||
widget.sheet.uuid,
|
widget.sheet.uuid,
|
||||||
_currentPage + 1,
|
_currentPage + 1,
|
||||||
rightJson.isEmpty || rightJson == '[]' ? null : rightJson,
|
rightHasContent ? rightJson : null,
|
||||||
|
now,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Upload right page to server
|
||||||
|
_syncService.uploadAnnotation(
|
||||||
|
sheetUuid: widget.sheet.uuid,
|
||||||
|
page: _currentPage + 1,
|
||||||
|
annotationsJson: rightJson,
|
||||||
|
lastModified: now,
|
||||||
|
);
|
||||||
|
|
||||||
|
_rightDrawingController.markSaved();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,7 +225,7 @@ class _SheetViewerPageState extends State<SheetViewerPage>
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _toggleFullscreen() {
|
void _toggleFullscreen() {
|
||||||
FullScreen.setFullScreen(!widget.config.fullscreen);
|
FullScreen.setFullScreen(!FullScreen.isFullScreen);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -276,6 +316,26 @@ class _SheetViewerPageState extends State<SheetViewerPage>
|
|||||||
: 'Enter Fullscreen',
|
: 'Enter Fullscreen',
|
||||||
onPressed: _toggleFullscreen,
|
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(
|
IconButton(
|
||||||
icon: Icon(_isPaintMode ? Icons.brush : Icons.brush_outlined),
|
icon: Icon(_isPaintMode ? Icons.brush : Icons.brush_outlined),
|
||||||
tooltip: _isPaintMode ? 'Exit Paint Mode' : 'Enter Paint Mode',
|
tooltip: _isPaintMode ? 'Exit Paint Mode' : 'Enter Paint Mode',
|
||||||
|
|||||||
+1
-1
@@ -39,7 +39,7 @@ Future<void> _initializePlugins() async {
|
|||||||
// PDF rendering (especially needed for web)
|
// PDF rendering (especially needed for web)
|
||||||
pdfrxFlutterInitialize();
|
pdfrxFlutterInitialize();
|
||||||
|
|
||||||
// Local storage (not supported on web)
|
// Local storage (not supported or necessary on web)
|
||||||
if (!kIsWeb) {
|
if (!kIsWeb) {
|
||||||
final dir = await getApplicationDocumentsDirectory();
|
final dir = await getApplicationDocumentsDirectory();
|
||||||
Hive.init(dir.path);
|
Hive.init(dir.path);
|
||||||
|
|||||||
@@ -29,9 +29,7 @@ class _EditSheetBottomSheetState extends State<EditSheetBottomSheet> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_nameController = TextEditingController(text: widget.sheet.name);
|
_nameController = TextEditingController(text: widget.sheet.name);
|
||||||
_composerController = TextEditingController(
|
_composerController = TextEditingController(text: widget.sheet.composer);
|
||||||
text: widget.sheet.composerName,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -88,7 +86,6 @@ class _EditSheetBottomSheetState extends State<EditSheetBottomSheet> {
|
|||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _composerController,
|
controller: _composerController,
|
||||||
validator: _validateNotEmpty,
|
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Composer',
|
labelText: 'Composer',
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
|
|||||||
+10
-20
@@ -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")
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
|||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
pdfium_flutter
|
jni
|
||||||
)
|
)
|
||||||
|
|
||||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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));
|
|
||||||
}
|
|
||||||
@@ -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_
|
|
||||||
@@ -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);
|
||||||
@@ -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));
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -1,3 +1,3 @@
|
|||||||
# This file was automatically generated by nix-shell.
|
# This file was automatically generated by nix-shell.
|
||||||
sdk.dir=/nix/store/ri84qrj5wk61x6cja29cfcv5x0iarhs5-androidsdk/libexec/android-sdk
|
sdk.dir=/nix/store/xxvb6m6dqjgdxk9xvwk8vlwapm8pgsdk-androidsdk/libexec/android-sdk
|
||||||
ndk.dir=/nix/store/ri84qrj5wk61x6cja29cfcv5x0iarhs5-androidsdk/libexec/android-sdk/ndk-bundle
|
ndk.dir=/nix/store/xxvb6m6dqjgdxk9xvwk8vlwapm8pgsdk-androidsdk/libexec/android-sdk/ndk-bundle
|
||||||
|
|||||||
+146
-122
@@ -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:
|
||||||
@@ -69,10 +69,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: code_assets
|
name: code_assets
|
||||||
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
|
sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.0"
|
version: "1.2.1"
|
||||||
collection:
|
collection:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -93,10 +93,10 @@ packages:
|
|||||||
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"
|
||||||
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: d07d37192dbf97461359c1518788f203b0c9102cfd2c35a716b823741219542c
|
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.5"
|
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:
|
||||||
@@ -170,26 +178,26 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: flutter_secure_storage
|
name: flutter_secure_storage
|
||||||
sha256: da922f2aab2d733db7e011a6bcc4a825b844892d4edd6df83ff156b09a9b2e40
|
sha256: "7686b1d6a29985dcbb808c59518226e603e3bfa7c0ddfd1a0d00e4cda77c868e"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "10.0.0"
|
version: "10.3.1"
|
||||||
flutter_secure_storage_darwin:
|
flutter_secure_storage_darwin:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: flutter_secure_storage_darwin
|
name: flutter_secure_storage_darwin
|
||||||
sha256: "8878c25136a79def1668c75985e8e193d9d7d095453ec28730da0315dc69aee3"
|
sha256: "82329fa5cdf343773b1b6897dea959105a29f092454259edff92f9f6637e8149"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.2.0"
|
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: "2b5c76dce569ab752d55a1cee6a2242bcc11fdba927078fb88c503f150767cda"
|
sha256: a5f35ddab43cf5c8215d2feb4ce1957851f28c5c37e6f04335066a0602087bf5
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.0"
|
version: "3.0.1"
|
||||||
flutter_secure_storage_platform_interface:
|
flutter_secure_storage_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -202,18 +210,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: flutter_secure_storage_web
|
name: flutter_secure_storage_web
|
||||||
sha256: "6a1137df62b84b54261dca582c1c09ea72f4f9a4b2fcee21b025964132d5d0c3"
|
sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.0"
|
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: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613"
|
sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.0"
|
version: "4.2.2"
|
||||||
flutter_test:
|
flutter_test:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -224,14 +232,6 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
glob:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: glob
|
|
||||||
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.1.3"
|
|
||||||
hive:
|
hive:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -244,10 +244,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: hooks
|
name: hooks
|
||||||
sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6"
|
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.1"
|
version: "2.0.2"
|
||||||
http:
|
http:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -268,18 +268,34 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: image
|
name: image
|
||||||
sha256: "492bd52f6c4fbb6ee41f781ff27765ce5f627910e1e0cbecfa3d9add5562604c"
|
sha256: "6300175e00616bbc832e2fc91bfa4d776af5402c81c7151bee6905bb08473c52"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.7.2"
|
version: "4.9.1"
|
||||||
|
jni:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: jni
|
||||||
|
sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
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: "805fa86df56383000f640384b282ce0cb8431f1a7a2396de92fb66186d8c57df"
|
sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.10.0"
|
version: "4.12.0"
|
||||||
jwt_decoder:
|
jwt_decoder:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -332,58 +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: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.17.0"
|
version: "1.18.0"
|
||||||
native_toolchain_c:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: native_toolchain_c
|
|
||||||
sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.17.4"
|
|
||||||
objective_c:
|
objective_c:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: objective_c
|
name: objective_c
|
||||||
sha256: "983c7fa1501f6dcc0cb7af4e42072e9993cb28d73604d25ebf4dab08165d997e"
|
sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "9.2.5"
|
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:
|
||||||
@@ -396,18 +412,18 @@ 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: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e
|
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.22"
|
version: "2.3.1"
|
||||||
path_provider_foundation:
|
path_provider_foundation:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -420,18 +436,18 @@ packages:
|
|||||||
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:
|
||||||
@@ -444,42 +460,42 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: pdfium_dart
|
name: pdfium_dart
|
||||||
sha256: f1683b9070ddc5c9189c6ee008c285791da66328ce1b882c7162d3393f3a4a74
|
sha256: "86e95c66b09f3245b95c4924f2edce8d4f7c9786876e5c5ee8e36b104a94bbb0"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.1.3"
|
version: "0.2.5"
|
||||||
pdfium_flutter:
|
pdfium_flutter:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: pdfium_flutter
|
name: pdfium_flutter
|
||||||
sha256: "0c8b7d5d11d20a1486eade599648e907067568955bd14a1b06de076a968b60a1"
|
sha256: "420ba8e7673b54da387ceeeb18a72c8bc6e4452128dbb391dca900c748f9e9ba"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.1.9"
|
version: "0.2.2"
|
||||||
pdfrx:
|
pdfrx:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: pdfrx
|
name: pdfrx
|
||||||
sha256: e32e0c786528eec2b3c56b43f59ef1debce3a27c7accd862b95413f949afcfa9
|
sha256: e0ca318004c3f32144db8e74fa612abb80ebe79004677293af74ed9af119f47f
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.24"
|
version: "2.4.4"
|
||||||
pdfrx_engine:
|
pdfrx_engine:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: pdfrx_engine
|
name: pdfrx_engine
|
||||||
sha256: a8914433d1f6188b903c53d36b9d7dc908bfa89131591a9db22f1a22470d3a48
|
sha256: "89865e158ced818690ab207a13a34a65803cd67ee1bec9f5f87838eb5a79600b"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.3.9"
|
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:
|
||||||
@@ -492,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:
|
||||||
@@ -524,10 +540,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: petitparser
|
name: petitparser
|
||||||
sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1"
|
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.1"
|
version: "7.0.2"
|
||||||
platform:
|
platform:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -548,10 +564,10 @@ 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:
|
pub_semver:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -560,6 +576,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
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:
|
||||||
@@ -572,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
|
||||||
@@ -617,50 +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"
|
||||||
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: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88
|
sha256: d0548f9d7422a2dae99ec6f8b0a3074463b132d216fa5ba0d230eeefc901983b
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.2+2"
|
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:
|
||||||
@@ -689,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:
|
||||||
@@ -705,10 +729,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.7"
|
version: "0.7.11"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -729,18 +753,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: url_launcher_android
|
name: url_launcher_android
|
||||||
sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611"
|
sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.3.28"
|
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: cfde38aa257dae62ffe79c87fab20165dfdf6988c1d31b58ebf59b9106062aad
|
sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.3.6"
|
version: "6.4.1"
|
||||||
url_launcher_linux:
|
url_launcher_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -769,10 +793,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: url_launcher_web
|
name: url_launcher_web
|
||||||
sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f
|
sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.2"
|
version: "2.4.3"
|
||||||
url_launcher_windows:
|
url_launcher_windows:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -785,10 +809,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: uuid
|
name: uuid
|
||||||
sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8
|
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.5.2"
|
version: "4.5.3"
|
||||||
vector_math:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -801,10 +825,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: vm_service
|
name: vm_service
|
||||||
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
|
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "15.0.2"
|
version: "15.2.0"
|
||||||
web:
|
web:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -817,10 +841,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: win32
|
name: win32
|
||||||
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
|
sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.15.0"
|
version: "6.3.0"
|
||||||
window_manager:
|
window_manager:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -841,10 +865,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: xml
|
name: xml
|
||||||
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
|
sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.6.1"
|
version: "7.0.1"
|
||||||
yaml:
|
yaml:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -854,5 +878,5 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.3"
|
version: "3.1.3"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.10.3 <4.0.0"
|
dart: ">=3.12.0 <4.0.0"
|
||||||
flutter: ">=3.38.4"
|
flutter: ">=3.44.0"
|
||||||
|
|||||||
+3
-3
@@ -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
|
||||||
@@ -43,12 +43,12 @@ dependencies:
|
|||||||
flutter_cache_manager: ^3.4.1
|
flutter_cache_manager: ^3.4.1
|
||||||
flutter_secure_storage: ^10.0.0
|
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_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:
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
|||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
pdfium_flutter
|
jni
|
||||||
)
|
)
|
||||||
|
|
||||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||||
|
|||||||
Reference in New Issue
Block a user