mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-07-04 10:03:39 +02:00
Initial commit: offline-first CRDT notes app (capture + GitHub sync)
Flutter app for Android + Linux desktop. Captures ideas with per-keystroke local autosave to a CRDT-backed SQLite store (sqlite_crdt), and syncs through a private GitHub repo using per-device changeset files (conflict-free last-writer-wins merge). Includes GitHub OAuth device-flow sign-in with PAT fallback, a barebones notes list, and sync settings. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
commit
d48bcd24f7
45
.gitignore
vendored
Normal file
45
.gitignore
vendored
Normal file
@ -0,0 +1,45 @@
|
||||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.build/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
.swiftpm/
|
||||
migrate_working_dir/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
#.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
**/doc/api/
|
||||
**/ios/Flutter/.last_build_id
|
||||
.dart_tool/
|
||||
.flutter-plugins-dependencies
|
||||
.pub-cache/
|
||||
.pub/
|
||||
/build/
|
||||
/coverage/
|
||||
|
||||
# Symbolication related
|
||||
app.*.symbols
|
||||
|
||||
# Obfuscation related
|
||||
app.*.map.json
|
||||
|
||||
# Android Studio will place build artifacts here
|
||||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
33
.metadata
Normal file
33
.metadata
Normal file
@ -0,0 +1,33 @@
|
||||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: "c9a6c484230f8b5e408ec57be1ef71dee1e77020"
|
||||
channel: "stable"
|
||||
|
||||
project_type: app
|
||||
|
||||
# Tracks metadata for the flutter migrate command
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020
|
||||
base_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020
|
||||
- platform: android
|
||||
create_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020
|
||||
base_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020
|
||||
- platform: linux
|
||||
create_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020
|
||||
base_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020
|
||||
|
||||
# User provided section
|
||||
|
||||
# List of Local paths (relative to this file) that should be
|
||||
# ignored by the migrate tool.
|
||||
#
|
||||
# Files that are not part of the templates will be ignored by default.
|
||||
unmanaged_files:
|
||||
- 'lib/main.dart'
|
||||
- 'ios/Runner.xcodeproj/project.pbxproj'
|
||||
17
README.md
Normal file
17
README.md
Normal file
@ -0,0 +1,17 @@
|
||||
# todo
|
||||
|
||||
A new Flutter project.
|
||||
|
||||
## Getting Started
|
||||
|
||||
This project is a starting point for a Flutter application.
|
||||
|
||||
A few resources to get you started if this is your first Flutter project:
|
||||
|
||||
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
|
||||
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
|
||||
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
|
||||
|
||||
For help getting started with Flutter development, view the
|
||||
[online documentation](https://docs.flutter.dev/), which offers tutorials,
|
||||
samples, guidance on mobile development, and a full API reference.
|
||||
28
analysis_options.yaml
Normal file
28
analysis_options.yaml
Normal file
@ -0,0 +1,28 @@
|
||||
# This file configures the analyzer, which statically analyzes Dart code to
|
||||
# check for errors, warnings, and lints.
|
||||
#
|
||||
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
|
||||
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
|
||||
# invoked from the command line by running `flutter analyze`.
|
||||
|
||||
# The following line activates a set of recommended lints for Flutter apps,
|
||||
# packages, and plugins designed to encourage good coding practices.
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
linter:
|
||||
# The lint rules applied to this project can be customized in the
|
||||
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||
# included above or to enable additional rules. A list of all available lints
|
||||
# and their documentation is published at https://dart.dev/lints.
|
||||
#
|
||||
# Instead of disabling a lint rule for the entire project in the
|
||||
# section below, it can also be suppressed for a single line of code
|
||||
# or a specific dart file by using the `// ignore: name_of_lint` and
|
||||
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
|
||||
# producing the lint.
|
||||
rules:
|
||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
14
android/.gitignore
vendored
Normal file
14
android/.gitignore
vendored
Normal file
@ -0,0 +1,14 @@
|
||||
gradle-wrapper.jar
|
||||
/.gradle
|
||||
/captures/
|
||||
/gradlew
|
||||
/gradlew.bat
|
||||
/local.properties
|
||||
GeneratedPluginRegistrant.java
|
||||
.cxx/
|
||||
|
||||
# Remember to never publicly share your keystore.
|
||||
# See https://flutter.dev/to/reference-keystore
|
||||
key.properties
|
||||
**/*.keystore
|
||||
**/*.jks
|
||||
45
android/app/build.gradle.kts
Normal file
45
android/app/build.gradle.kts
Normal file
@ -0,0 +1,45 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "dev.kuhy.todo"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId = "dev.kuhy.todo"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = flutter.minSdkVersion
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
7
android/app/src/debug/AndroidManifest.xml
Normal file
7
android/app/src/debug/AndroidManifest.xml
Normal file
@ -0,0 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
49
android/app/src/main/AndroidManifest.xml
Normal file
49
android/app/src/main/AndroidManifest.xml
Normal file
@ -0,0 +1,49 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- Required for GitHub sync. flutter create only adds this to the
|
||||
debug/profile manifests, so without it release builds silently
|
||||
lose all network access. -->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<application
|
||||
android:label="todo"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||
the Android process has started. This theme is visible to the user
|
||||
while the Flutter UI initializes. After that, this theme continues
|
||||
to determine the Window background behind the Flutter UI. -->
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"
|
||||
/>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
||||
|
||||
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
@ -0,0 +1,5 @@
|
||||
package dev.kuhy.todo
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
12
android/app/src/main/res/drawable-v21/launch_background.xml
Normal file
12
android/app/src/main/res/drawable-v21/launch_background.xml
Normal file
@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="?android:colorBackground" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
12
android/app/src/main/res/drawable/launch_background.xml
Normal file
12
android/app/src/main/res/drawable/launch_background.xml
Normal file
@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@android:color/white" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 544 B |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 442 B |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 721 B |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
18
android/app/src/main/res/values-night/styles.xml
Normal file
18
android/app/src/main/res/values-night/styles.xml
Normal file
@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
18
android/app/src/main/res/values/styles.xml
Normal file
18
android/app/src/main/res/values/styles.xml
Normal file
@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
7
android/app/src/profile/AndroidManifest.xml
Normal file
7
android/app/src/profile/AndroidManifest.xml
Normal file
@ -0,0 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
24
android/build.gradle.kts
Normal file
24
android/build.gradle.kts
Normal file
@ -0,0 +1,24 @@
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
val newBuildDir: Directory =
|
||||
rootProject.layout.buildDirectory
|
||||
.dir("../../build")
|
||||
.get()
|
||||
rootProject.layout.buildDirectory.value(newBuildDir)
|
||||
|
||||
subprojects {
|
||||
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
||||
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
||||
}
|
||||
subprojects {
|
||||
project.evaluationDependsOn(":app")
|
||||
}
|
||||
|
||||
tasks.register<Delete>("clean") {
|
||||
delete(rootProject.layout.buildDirectory)
|
||||
}
|
||||
6
android/gradle.properties
Normal file
6
android/gradle.properties
Normal file
@ -0,0 +1,6 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
# This newDsl flag was added by the Flutter template
|
||||
android.newDsl=false
|
||||
# This builtInKotlin flag was added by the Flutter template
|
||||
android.builtInKotlin=false
|
||||
5
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
5
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip
|
||||
26
android/settings.gradle.kts
Normal file
26
android/settings.gradle.kts
Normal file
@ -0,0 +1,26 @@
|
||||
pluginManagement {
|
||||
val flutterSdkPath =
|
||||
run {
|
||||
val properties = java.util.Properties()
|
||||
file("local.properties").inputStream().use { properties.load(it) }
|
||||
val flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
|
||||
flutterSdkPath
|
||||
}
|
||||
|
||||
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "9.0.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.3.20" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
32
docs/initial_design.md
Normal file
32
docs/initial_design.md
Normal file
@ -0,0 +1,32 @@
|
||||
What:
|
||||
toDo android and gnu/linux app
|
||||
|
||||
Why:
|
||||
I get an influx of ideas every now and then, whether on my pc or on my mobile, I would like to write them down quickly and store them both on local device and on some remote server to use them later
|
||||
|
||||
Functional requirements:
|
||||
1. Offline first, should work without internet at all
|
||||
2. Sync between mobile and pc seamlessly and resolve merge conflicts (suggestion in technical)
|
||||
3. Store those notes on remote private service (suggestion in technical)
|
||||
4. The same experience on both mobile and pc
|
||||
5. Option to export all notes to a single recoverable sync files
|
||||
|
||||
Using the app:
|
||||
1. When opening app show an input text where you can immediately write down the idea and it gets saved character by character to local device (no interruptions, immediate), after user clicks "save" sync it with other devices running this app and with remote services
|
||||
2. User can also open history of all available ideas with options to filter and sort them based on:
|
||||
a) when they were originally created
|
||||
b) when they were last modified
|
||||
c) alphabetically
|
||||
d) priority
|
||||
3. Option to tier ideas based on their priority
|
||||
|
||||
|
||||
Technical requirements:
|
||||
1. Supports only markdown
|
||||
2. Same codebase for mobile and pc
|
||||
3. 100% test coverage
|
||||
4. Hardest available lint tools for toolstack
|
||||
5. For merge conflicts just use git ?
|
||||
6. For remote private service use private github gits? In any way the app should be 100% compatible with this remote service, the service itself should be free for small text files we will be using and it should be private
|
||||
|
||||
The most important part: I do not want to lose those ideas thats why I want them to be stored in so many distributed places (locally, between my devices and on remote services)
|
||||
82
lib/data/note.dart
Normal file
82
lib/data/note.dart
Normal file
@ -0,0 +1,82 @@
|
||||
/// Domain model for a single idea/note.
|
||||
///
|
||||
/// Notes are stored locally in a CRDT-backed SQLite table (see
|
||||
/// [NoteRepository]). The CRDT layer manages its own metadata columns
|
||||
/// (`hlc`, `node_id`, `modified`, `is_deleted`); the fields here are the
|
||||
/// user-facing data only.
|
||||
library;
|
||||
|
||||
/// Priority tier for a note, used for sorting and visual grouping.
|
||||
///
|
||||
/// Stored as the integer [value] so ordering is trivial in SQL.
|
||||
enum Priority {
|
||||
none(0),
|
||||
low(1),
|
||||
medium(2),
|
||||
high(3);
|
||||
|
||||
const Priority(this.value);
|
||||
|
||||
/// Integer persisted in the database; higher means more important.
|
||||
final int value;
|
||||
|
||||
/// Rebuilds a [Priority] from its stored [value], defaulting to [none]
|
||||
/// for any unknown/legacy value so reads never throw.
|
||||
static Priority fromValue(int? value) {
|
||||
return Priority.values.firstWhere(
|
||||
(p) => p.value == value,
|
||||
orElse: () => Priority.none,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// An immutable idea/note record.
|
||||
class Note {
|
||||
const Note({
|
||||
required this.id,
|
||||
required this.text,
|
||||
required this.priority,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
/// Stable unique id (UUID v4). Also the CRDT primary key.
|
||||
final String id;
|
||||
|
||||
/// The markdown body of the idea.
|
||||
final String text;
|
||||
|
||||
/// Priority tier for sorting/filtering.
|
||||
final Priority priority;
|
||||
|
||||
/// When the note was first created (set once, never changed).
|
||||
final DateTime createdAt;
|
||||
|
||||
/// When the note's content was last modified locally.
|
||||
final DateTime updatedAt;
|
||||
|
||||
/// Builds a [Note] from a raw CRDT query row.
|
||||
///
|
||||
/// Timestamps are stored as ISO-8601 strings for human-readable,
|
||||
/// lexicographically sortable values.
|
||||
factory Note.fromRow(Map<String, Object?> row) {
|
||||
return Note(
|
||||
id: row['id'] as String,
|
||||
text: (row['text'] as String?) ?? '',
|
||||
priority: Priority.fromValue(row['priority'] as int?),
|
||||
createdAt: DateTime.parse(row['created_at'] as String),
|
||||
updatedAt: DateTime.parse(row['updated_at'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns a copy with selected fields replaced.
|
||||
Note copyWith({String? text, Priority? priority, DateTime? updatedAt}) {
|
||||
return Note(
|
||||
id: id,
|
||||
text: text ?? this.text,
|
||||
priority: priority ?? this.priority,
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
149
lib/data/note_repository.dart
Normal file
149
lib/data/note_repository.dart
Normal file
@ -0,0 +1,149 @@
|
||||
import 'package:sqlite_crdt/sqlite_crdt.dart';
|
||||
|
||||
import 'note.dart';
|
||||
|
||||
/// How the history list should be ordered.
|
||||
enum NoteSort {
|
||||
createdDesc,
|
||||
modifiedDesc,
|
||||
alphabetical,
|
||||
priorityDesc,
|
||||
}
|
||||
|
||||
/// Local-first persistence for [Note]s, backed by a CRDT SQLite database.
|
||||
///
|
||||
/// Every write goes straight to local storage so the app works fully
|
||||
/// offline. The CRDT metadata (Hybrid Logical Clock timestamps per row)
|
||||
/// lets a future sync layer merge two devices conflict-free using
|
||||
/// last-writer-wins per note. This class owns the schema and exposes a
|
||||
/// small, intention-revealing API; SQL never leaks past this boundary.
|
||||
class NoteRepository {
|
||||
NoteRepository._(this._crdt);
|
||||
|
||||
final SqliteCrdt _crdt;
|
||||
|
||||
/// Opens (or creates) the database at [path] and ensures the schema.
|
||||
///
|
||||
/// [path] should be an absolute file path on desktop/mobile, or the
|
||||
/// special in-memory path used by tests.
|
||||
static Future<NoteRepository> open(String path) async {
|
||||
final crdt = await SqliteCrdt.open(
|
||||
path,
|
||||
version: 1,
|
||||
onCreate: (db, version) async {
|
||||
// Plain columns only; the CRDT layer adds its own bookkeeping
|
||||
// columns transparently. ISO-8601 strings keep timestamps both
|
||||
// human-readable and lexicographically sortable.
|
||||
await db.execute('''
|
||||
CREATE TABLE notes (
|
||||
id TEXT NOT NULL,
|
||||
text TEXT NOT NULL DEFAULT '',
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
)
|
||||
''');
|
||||
},
|
||||
);
|
||||
return NoteRepository._(crdt);
|
||||
}
|
||||
|
||||
/// Opens a transient in-memory database; intended for tests.
|
||||
static Future<NoteRepository> openInMemory() async {
|
||||
final crdt = await SqliteCrdt.openInMemory(
|
||||
version: 1,
|
||||
onCreate: (db, version) async {
|
||||
await db.execute('''
|
||||
CREATE TABLE notes (
|
||||
id TEXT NOT NULL,
|
||||
text TEXT NOT NULL DEFAULT '',
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
)
|
||||
''');
|
||||
},
|
||||
);
|
||||
return NoteRepository._(crdt);
|
||||
}
|
||||
|
||||
/// Inserts a new note or updates the existing one with the same [id].
|
||||
///
|
||||
/// This is the single write path used by the capture screen's
|
||||
/// character-by-character autosave: it is cheap and idempotent.
|
||||
Future<void> upsert(Note note) async {
|
||||
await _crdt.execute(
|
||||
'''
|
||||
INSERT INTO notes (id, text, priority, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
text = ?2,
|
||||
priority = ?3,
|
||||
updated_at = ?5
|
||||
''',
|
||||
[
|
||||
note.id,
|
||||
note.text,
|
||||
note.priority.value,
|
||||
note.createdAt.toIso8601String(),
|
||||
note.updatedAt.toIso8601String(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Soft-deletes a note. The CRDT keeps a tombstone so the deletion
|
||||
/// propagates on the next sync instead of resurrecting the row.
|
||||
Future<void> delete(String id) async {
|
||||
await _crdt.execute('DELETE FROM notes WHERE id = ?1', [id]);
|
||||
}
|
||||
|
||||
/// Returns all live notes ordered by [sort].
|
||||
Future<List<Note>> listNotes({
|
||||
NoteSort sort = NoteSort.modifiedDesc,
|
||||
}) async {
|
||||
final rows = await _crdt
|
||||
.query('SELECT * FROM notes WHERE is_deleted = 0 ${_orderBy(sort)}');
|
||||
return rows.map(Note.fromRow).toList();
|
||||
}
|
||||
|
||||
/// Emits the ordered note list and re-emits whenever the table changes,
|
||||
/// so the UI can stay in sync without manual refreshes.
|
||||
Stream<List<Note>> watchNotes({
|
||||
NoteSort sort = NoteSort.modifiedDesc,
|
||||
}) {
|
||||
return _crdt
|
||||
.watch('SELECT * FROM notes WHERE is_deleted = 0 ${_orderBy(sort)}')
|
||||
.map((rows) => rows.map(Note.fromRow).toList());
|
||||
}
|
||||
|
||||
/// This device's stable CRDT node id. Used to name its changeset file
|
||||
/// in the sync repo so two devices never write the same file.
|
||||
String get nodeId => _crdt.nodeId;
|
||||
|
||||
/// Returns this device's full CRDT changeset for upload.
|
||||
Future<CrdtChangeset> getChangeset() => _crdt.getChangeset();
|
||||
|
||||
/// Merges a remote changeset into local storage (conflict-free,
|
||||
/// last-writer-wins per row via the Hybrid Logical Clock).
|
||||
Future<void> merge(CrdtChangeset changeset) => _crdt.merge(changeset);
|
||||
|
||||
/// Closes the underlying database.
|
||||
Future<void> close() => _crdt.close();
|
||||
|
||||
/// Maps a [NoteSort] to a SQL ORDER BY clause. Centralised so the sort
|
||||
/// options used by the live and one-shot queries can never drift apart.
|
||||
String _orderBy(NoteSort sort) {
|
||||
switch (sort) {
|
||||
case NoteSort.createdDesc:
|
||||
return 'ORDER BY created_at DESC';
|
||||
case NoteSort.modifiedDesc:
|
||||
return 'ORDER BY updated_at DESC';
|
||||
case NoteSort.alphabetical:
|
||||
return 'ORDER BY text COLLATE NOCASE ASC';
|
||||
case NoteSort.priorityDesc:
|
||||
return 'ORDER BY priority DESC, updated_at DESC';
|
||||
}
|
||||
}
|
||||
}
|
||||
46
lib/main.dart
Normal file
46
lib/main.dart
Normal file
@ -0,0 +1,46 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
|
||||
import 'data/note_repository.dart';
|
||||
import 'ui/capture_screen.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Desktop platforms need the FFI sqlite implementation initialised
|
||||
// before any database is opened; mobile uses the bundled library.
|
||||
if (!kIsWeb && (Platform.isLinux || Platform.isWindows || Platform.isMacOS)) {
|
||||
sqfliteFfiInit();
|
||||
}
|
||||
|
||||
final dir = await getApplicationSupportDirectory();
|
||||
final dbPath = p.join(dir.path, 'todo.db');
|
||||
final repository = await NoteRepository.open(dbPath);
|
||||
|
||||
runApp(TodoApp(repository: repository));
|
||||
}
|
||||
|
||||
/// Root widget. Holds the single [NoteRepository] instance and hands it
|
||||
/// to the screens that need it.
|
||||
class TodoApp extends StatelessWidget {
|
||||
const TodoApp({required this.repository, super.key});
|
||||
|
||||
final NoteRepository repository;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'todo',
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
|
||||
useMaterial3: true,
|
||||
),
|
||||
home: CaptureScreen(repository: repository),
|
||||
);
|
||||
}
|
||||
}
|
||||
154
lib/sync/github_client.dart
Normal file
154
lib/sync/github_client.dart
Normal file
@ -0,0 +1,154 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
/// A file entry in a GitHub repository directory listing.
|
||||
class GitHubFile {
|
||||
const GitHubFile({required this.name, required this.path, required this.sha});
|
||||
|
||||
final String name;
|
||||
final String path;
|
||||
|
||||
/// Git blob SHA; required to update or delete the file.
|
||||
final String sha;
|
||||
}
|
||||
|
||||
/// Raised when the GitHub API returns an unexpected status.
|
||||
class GitHubApiException implements Exception {
|
||||
GitHubApiException(this.statusCode, this.message);
|
||||
|
||||
final int statusCode;
|
||||
final String message;
|
||||
|
||||
@override
|
||||
String toString() => 'GitHubApiException($statusCode): $message';
|
||||
}
|
||||
|
||||
/// Minimal GitHub REST client scoped to the Contents API.
|
||||
///
|
||||
/// This is the only component that holds the access token, mirroring the
|
||||
/// "server holds credentials" pattern: the rest of the app deals in notes
|
||||
/// and changesets, never in raw HTTP or secrets. The token can come from a
|
||||
/// pasted PAT today or the OAuth device flow later — this class does not
|
||||
/// care how it was obtained.
|
||||
class GitHubClient {
|
||||
GitHubClient({
|
||||
required this.owner,
|
||||
required this.repo,
|
||||
required String token,
|
||||
http.Client? httpClient,
|
||||
this.branch = 'main',
|
||||
}) // Dart forbids private named params, so this can't be an initializing
|
||||
// formal; assign it explicitly.
|
||||
// ignore: prefer_initializing_formals
|
||||
: _token = token,
|
||||
_http = httpClient ?? http.Client();
|
||||
|
||||
final String owner;
|
||||
final String repo;
|
||||
final String branch;
|
||||
final String _token;
|
||||
final http.Client _http;
|
||||
|
||||
static const _apiBase = 'https://api.github.com';
|
||||
|
||||
Map<String, String> get _headers => {
|
||||
'Authorization': 'Bearer $_token',
|
||||
'Accept': 'application/vnd.github+json',
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
'User-Agent': 'todo-app-sync',
|
||||
};
|
||||
|
||||
Uri _contentsUri(String path) =>
|
||||
Uri.parse('$_apiBase/repos/$owner/$repo/contents/$path');
|
||||
|
||||
/// Lists the files directly under [dirPath]. Returns an empty list if the
|
||||
/// directory does not exist yet (e.g. before the first sync).
|
||||
Future<List<GitHubFile>> listDirectory(String dirPath) async {
|
||||
final res = await _http.get(
|
||||
_contentsUri(dirPath).replace(queryParameters: {'ref': branch}),
|
||||
headers: _headers,
|
||||
);
|
||||
if (res.statusCode == 404) return [];
|
||||
_ensureOk(res, 'list $dirPath');
|
||||
final decoded = jsonDecode(res.body);
|
||||
if (decoded is! List) return [];
|
||||
return decoded
|
||||
.cast<Map<String, dynamic>>()
|
||||
.where((e) => e['type'] == 'file')
|
||||
.map((e) => GitHubFile(
|
||||
name: e['name'] as String,
|
||||
path: e['path'] as String,
|
||||
sha: e['sha'] as String,
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Fetches and UTF-8-decodes a file's contents. Returns null if absent.
|
||||
Future<String?> getFileText(String path) async {
|
||||
final res = await _http.get(
|
||||
_contentsUri(path).replace(queryParameters: {'ref': branch}),
|
||||
headers: _headers,
|
||||
);
|
||||
if (res.statusCode == 404) return null;
|
||||
_ensureOk(res, 'get $path');
|
||||
final json = jsonDecode(res.body) as Map<String, dynamic>;
|
||||
// GitHub base64-encodes file content, wrapping lines at 60 chars.
|
||||
final raw = (json['content'] as String).replaceAll('\n', '');
|
||||
return utf8.decode(base64.decode(raw));
|
||||
}
|
||||
|
||||
/// Creates or updates [path] with [text]. Pass the current [sha] when
|
||||
/// updating an existing file; omit it to create a new one.
|
||||
Future<void> putFileText(
|
||||
String path,
|
||||
String text, {
|
||||
String? sha,
|
||||
String? message,
|
||||
}) async {
|
||||
final body = <String, dynamic>{
|
||||
'message': message ?? 'sync: update $path',
|
||||
'content': base64.encode(utf8.encode(text)),
|
||||
'branch': branch,
|
||||
'sha': ?sha,
|
||||
};
|
||||
final res = await _http.put(
|
||||
_contentsUri(path),
|
||||
headers: _headers,
|
||||
body: jsonEncode(body),
|
||||
);
|
||||
_ensureOk(res, 'put $path');
|
||||
}
|
||||
|
||||
/// Deletes the file at [path] (requires its current [sha]).
|
||||
Future<void> deleteFile(String path, String sha, {String? message}) async {
|
||||
final res = await _http.delete(
|
||||
_contentsUri(path),
|
||||
headers: _headers,
|
||||
body: jsonEncode({
|
||||
'message': message ?? 'sync: delete $path',
|
||||
'sha': sha,
|
||||
'branch': branch,
|
||||
}),
|
||||
);
|
||||
_ensureOk(res, 'delete $path');
|
||||
}
|
||||
|
||||
/// Cheap auth/connectivity probe used by the settings "Test connection"
|
||||
/// button: succeeds only if the token can read the repo.
|
||||
Future<bool> canAccessRepo() async {
|
||||
final res = await _http.get(
|
||||
Uri.parse('$_apiBase/repos/$owner/$repo'),
|
||||
headers: _headers,
|
||||
);
|
||||
return res.statusCode == 200;
|
||||
}
|
||||
|
||||
void close() => _http.close();
|
||||
|
||||
void _ensureOk(http.Response res, String action) {
|
||||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||
throw GitHubApiException(res.statusCode, 'Failed to $action: ${res.body}');
|
||||
}
|
||||
}
|
||||
}
|
||||
144
lib/sync/github_device_auth.dart
Normal file
144
lib/sync/github_device_auth.dart
Normal file
@ -0,0 +1,144 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
/// First-stage response of the GitHub OAuth Device Flow: the code the user
|
||||
/// types on github.com and the URL to type it into.
|
||||
class DeviceCodeResponse {
|
||||
const DeviceCodeResponse({
|
||||
required this.deviceCode,
|
||||
required this.userCode,
|
||||
required this.verificationUri,
|
||||
required this.interval,
|
||||
required this.expiresIn,
|
||||
});
|
||||
|
||||
/// Opaque code the client polls with (not shown to the user).
|
||||
final String deviceCode;
|
||||
|
||||
/// Short code the user enters on the verification page.
|
||||
final String userCode;
|
||||
|
||||
/// Page the user opens to enter [userCode] (github.com/login/device).
|
||||
final String verificationUri;
|
||||
|
||||
/// Minimum seconds to wait between polls.
|
||||
final int interval;
|
||||
|
||||
/// Seconds until [deviceCode] expires.
|
||||
final int expiresIn;
|
||||
|
||||
factory DeviceCodeResponse.fromJson(Map<String, dynamic> json) {
|
||||
return DeviceCodeResponse(
|
||||
deviceCode: json['device_code'] as String,
|
||||
userCode: json['user_code'] as String,
|
||||
verificationUri: json['verification_uri'] as String,
|
||||
interval: (json['interval'] as int?) ?? 5,
|
||||
expiresIn: (json['expires_in'] as int?) ?? 900,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Raised when the device-flow authorization fails or is declined.
|
||||
class DeviceAuthException implements Exception {
|
||||
DeviceAuthException(this.code, this.message);
|
||||
|
||||
/// GitHub error code, e.g. `access_denied`, `expired_token`.
|
||||
final String code;
|
||||
final String message;
|
||||
|
||||
@override
|
||||
String toString() => 'DeviceAuthException($code): $message';
|
||||
}
|
||||
|
||||
/// Implements the GitHub OAuth **Device Flow** so the user can authorize the
|
||||
/// app by visiting a URL and entering a short code — no token pasting.
|
||||
///
|
||||
/// Device flow needs only a public `client_id` (no client secret), which
|
||||
/// makes it safe for a distributed app. The resulting access token is then
|
||||
/// used exactly like a PAT by [GitHubClient].
|
||||
///
|
||||
/// References:
|
||||
/// - POST https://github.com/login/device/code
|
||||
/// - POST https://github.com/login/oauth/access_token
|
||||
class GitHubDeviceAuth {
|
||||
GitHubDeviceAuth({
|
||||
required this.clientId,
|
||||
this.scope = 'repo',
|
||||
http.Client? httpClient,
|
||||
Future<void> Function(Duration)? delay,
|
||||
}) : _http = httpClient ?? http.Client(),
|
||||
// Indirection so tests can skip real waiting between polls.
|
||||
_delay = delay ?? Future<void>.delayed;
|
||||
|
||||
final String clientId;
|
||||
|
||||
/// OAuth scope requested. `repo` is required for private-repo contents.
|
||||
final String scope;
|
||||
|
||||
final http.Client _http;
|
||||
final Future<void> Function(Duration) _delay;
|
||||
|
||||
static const _deviceCodeUrl = 'https://github.com/login/device/code';
|
||||
static const _tokenUrl = 'https://github.com/login/oauth/access_token';
|
||||
static const _grantType =
|
||||
'urn:ietf:params:oauth:grant-type:device_code';
|
||||
|
||||
/// Step 1: ask GitHub for a device + user code.
|
||||
Future<DeviceCodeResponse> requestDeviceCode() async {
|
||||
final res = await _http.post(
|
||||
Uri.parse(_deviceCodeUrl),
|
||||
headers: const {'Accept': 'application/json'},
|
||||
body: {'client_id': clientId, 'scope': scope},
|
||||
);
|
||||
if (res.statusCode != 200) {
|
||||
throw DeviceAuthException('http_${res.statusCode}', res.body);
|
||||
}
|
||||
return DeviceCodeResponse.fromJson(
|
||||
jsonDecode(res.body) as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
/// Step 2: poll until the user authorizes, returning the access token.
|
||||
///
|
||||
/// Honors GitHub's pacing protocol: `authorization_pending` keeps polling,
|
||||
/// `slow_down` increases the interval, and terminal errors throw a
|
||||
/// [DeviceAuthException].
|
||||
Future<String> pollForToken(DeviceCodeResponse device) async {
|
||||
var intervalSeconds = device.interval;
|
||||
final deadline = DateTime.now().add(Duration(seconds: device.expiresIn));
|
||||
|
||||
while (DateTime.now().isBefore(deadline)) {
|
||||
await _delay(Duration(seconds: intervalSeconds));
|
||||
final res = await _http.post(
|
||||
Uri.parse(_tokenUrl),
|
||||
headers: const {'Accept': 'application/json'},
|
||||
body: {
|
||||
'client_id': clientId,
|
||||
'device_code': device.deviceCode,
|
||||
'grant_type': _grantType,
|
||||
},
|
||||
);
|
||||
final json = jsonDecode(res.body) as Map<String, dynamic>;
|
||||
|
||||
final token = json['access_token'] as String?;
|
||||
if (token != null) return token;
|
||||
|
||||
switch (json['error'] as String?) {
|
||||
case 'authorization_pending':
|
||||
continue; // User has not finished authorizing yet.
|
||||
case 'slow_down':
|
||||
// GitHub asks us to back off; obey its new interval.
|
||||
intervalSeconds = (json['interval'] as int?) ?? intervalSeconds + 5;
|
||||
case final String error:
|
||||
throw DeviceAuthException(
|
||||
error, (json['error_description'] as String?) ?? error);
|
||||
case null:
|
||||
throw DeviceAuthException('unknown', 'Unexpected response: $json');
|
||||
}
|
||||
}
|
||||
throw DeviceAuthException('expired_token', 'Device code expired.');
|
||||
}
|
||||
|
||||
void close() => _http.close();
|
||||
}
|
||||
96
lib/sync/sync_service.dart
Normal file
96
lib/sync/sync_service.dart
Normal file
@ -0,0 +1,96 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:sqlite_crdt/sqlite_crdt.dart';
|
||||
|
||||
import '../data/note_repository.dart';
|
||||
import 'github_client.dart';
|
||||
|
||||
/// Outcome of a sync run, for surfacing in the UI.
|
||||
class SyncResult {
|
||||
const SyncResult({
|
||||
required this.mergedDevices,
|
||||
required this.pushed,
|
||||
});
|
||||
|
||||
/// How many other devices' changesets were pulled and merged.
|
||||
final int mergedDevices;
|
||||
|
||||
/// Whether this device pushed its own changeset.
|
||||
final bool pushed;
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'SyncResult(mergedDevices: $mergedDevices, pushed: $pushed)';
|
||||
}
|
||||
|
||||
/// Synchronises a [NoteRepository] with a GitHub repo used as dumb storage.
|
||||
///
|
||||
/// Design: each device owns exactly one file, `changesets/<nodeId>.json`,
|
||||
/// holding its full CRDT changeset. Because no two devices ever write the
|
||||
/// same file, there are **no git-level merge conflicts**. Data convergence
|
||||
/// is handled entirely by the CRDT layer: pulling every other device's
|
||||
/// changeset and [NoteRepository.merge]-ing it is commutative and
|
||||
/// idempotent, so repeated syncs in any order converge to the same state.
|
||||
class SyncService {
|
||||
const SyncService({this.changesetDir = 'changesets'});
|
||||
|
||||
/// Directory in the repo under which per-device changeset files live.
|
||||
final String changesetDir;
|
||||
|
||||
/// Runs a full pull-merge-push cycle. Safe to call repeatedly.
|
||||
Future<SyncResult> sync(NoteRepository repo, GitHubClient github) async {
|
||||
final nodeId = repo.nodeId;
|
||||
final ownFileName = '$nodeId.json';
|
||||
|
||||
// 1. Pull: list all device changeset files, merge everyone else's.
|
||||
final files = await github.listDirectory(changesetDir);
|
||||
var merged = 0;
|
||||
String? ownSha;
|
||||
for (final file in files) {
|
||||
if (file.name == ownFileName) {
|
||||
ownSha = file.sha; // Remember our file's SHA so we can update it.
|
||||
continue;
|
||||
}
|
||||
final text = await github.getFileText(file.path);
|
||||
if (text == null) continue;
|
||||
await repo.merge(_decodeChangeset(text));
|
||||
merged++;
|
||||
}
|
||||
|
||||
// 2. Push: upload our own (now-merged) changeset under our node id.
|
||||
final changeset = await repo.getChangeset();
|
||||
await github.putFileText(
|
||||
'$changesetDir/$ownFileName',
|
||||
_encodeChangeset(changeset),
|
||||
sha: ownSha,
|
||||
message: 'sync: $nodeId @ ${DateTime.now().toUtc().toIso8601String()}',
|
||||
);
|
||||
|
||||
return SyncResult(mergedDevices: merged, pushed: true);
|
||||
}
|
||||
|
||||
/// Serialises a changeset to pretty JSON. All values are already
|
||||
/// primitives (hlc/modified are stored as TEXT), so no custom encoding
|
||||
/// is needed.
|
||||
String _encodeChangeset(CrdtChangeset changeset) =>
|
||||
const JsonEncoder.withIndent(' ').convert(changeset);
|
||||
|
||||
/// Parses a JSON changeset back into the typed shape that
|
||||
/// [NoteRepository.merge] expects.
|
||||
///
|
||||
/// The `hlc` field must be revived from its string form into an [Hlc]
|
||||
/// object, because `validateChangeset` casts it directly to [Hlc].
|
||||
CrdtChangeset _decodeChangeset(String text) {
|
||||
final raw = jsonDecode(text) as Map<String, dynamic>;
|
||||
return raw.map(
|
||||
(table, records) => MapEntry(
|
||||
table,
|
||||
(records as List).map((r) {
|
||||
final record = (r as Map).cast<String, Object?>();
|
||||
record['hlc'] = Hlc.parse(record['hlc'] as String);
|
||||
return record;
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
71
lib/sync/sync_settings.dart
Normal file
71
lib/sync/sync_settings.dart
Normal file
@ -0,0 +1,71 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Locally-stored GitHub sync configuration.
|
||||
///
|
||||
/// NOTE: the token is currently stored in plain `SharedPreferences`. That is
|
||||
/// acceptable for a personal dogfood build, but should move to
|
||||
/// `flutter_secure_storage` (Android Keystore / libsecret) before this is
|
||||
/// considered done. Tracked as a follow-up.
|
||||
class SyncSettings {
|
||||
const SyncSettings({
|
||||
required this.owner,
|
||||
required this.repo,
|
||||
required this.token,
|
||||
this.clientId = '',
|
||||
});
|
||||
|
||||
final String owner;
|
||||
final String repo;
|
||||
final String token;
|
||||
|
||||
/// GitHub OAuth App client id used by the device-flow "Connect" button.
|
||||
/// Not a secret (device flow needs no client secret), so it is safe to
|
||||
/// store here and could later be shipped as a compile-time default.
|
||||
final String clientId;
|
||||
|
||||
/// True when enough is set to attempt a sync.
|
||||
bool get isConfigured =>
|
||||
owner.isNotEmpty && repo.isNotEmpty && token.isNotEmpty;
|
||||
|
||||
/// True when device-flow "Connect GitHub" can be offered.
|
||||
bool get canUseDeviceFlow => clientId.isNotEmpty;
|
||||
|
||||
static const _kOwner = 'sync.owner';
|
||||
static const _kRepo = 'sync.repo';
|
||||
static const _kToken = 'sync.token';
|
||||
static const _kClientId = 'sync.clientId';
|
||||
|
||||
/// Loads settings, defaulting the repo to `kuhyx/todo-sync` so the user
|
||||
/// only has to authorize on first run.
|
||||
static Future<SyncSettings> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return SyncSettings(
|
||||
owner: prefs.getString(_kOwner) ?? 'kuhyx',
|
||||
repo: prefs.getString(_kRepo) ?? 'todo-sync',
|
||||
token: prefs.getString(_kToken) ?? '',
|
||||
clientId: prefs.getString(_kClientId) ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> save() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_kOwner, owner);
|
||||
await prefs.setString(_kRepo, repo);
|
||||
await prefs.setString(_kToken, token);
|
||||
await prefs.setString(_kClientId, clientId);
|
||||
}
|
||||
|
||||
SyncSettings copyWith({
|
||||
String? owner,
|
||||
String? repo,
|
||||
String? token,
|
||||
String? clientId,
|
||||
}) {
|
||||
return SyncSettings(
|
||||
owner: owner ?? this.owner,
|
||||
repo: repo ?? this.repo,
|
||||
token: token ?? this.token,
|
||||
clientId: clientId ?? this.clientId,
|
||||
);
|
||||
}
|
||||
}
|
||||
238
lib/ui/capture_screen.dart
Normal file
238
lib/ui/capture_screen.dart
Normal file
@ -0,0 +1,238 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../data/note.dart';
|
||||
import '../data/note_repository.dart';
|
||||
import '../sync/github_client.dart';
|
||||
import '../sync/sync_service.dart';
|
||||
import '../sync/sync_settings.dart';
|
||||
import 'notes_list_screen.dart';
|
||||
import 'settings_screen.dart';
|
||||
|
||||
/// The landing screen: an always-focused text box for jotting an idea.
|
||||
///
|
||||
/// Per the product goal "no interruptions, immediate", text is persisted
|
||||
/// to local storage on *every* keystroke. A note row is created lazily on
|
||||
/// the first character typed, then updated in place. The explicit "Save"
|
||||
/// action finalises the current idea and clears the field for the next
|
||||
/// one (remote sync will hook in here later).
|
||||
class CaptureScreen extends StatefulWidget {
|
||||
const CaptureScreen({required this.repository, super.key});
|
||||
|
||||
final NoteRepository repository;
|
||||
|
||||
@override
|
||||
State<CaptureScreen> createState() => _CaptureScreenState();
|
||||
}
|
||||
|
||||
class _CaptureScreenState extends State<CaptureScreen> {
|
||||
static const _uuid = Uuid();
|
||||
|
||||
final TextEditingController _controller = TextEditingController();
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
|
||||
/// Id of the note currently being edited, or null before the first
|
||||
/// keystroke of a fresh draft.
|
||||
String? _draftId;
|
||||
DateTime? _draftCreatedAt;
|
||||
DateTime? _lastSavedAt;
|
||||
|
||||
final SyncService _syncService = const SyncService();
|
||||
SyncSettings? _settings;
|
||||
bool _syncing = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
SyncSettings.load().then((s) {
|
||||
if (mounted) setState(() => _settings = s);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Opens the settings screen and adopts any saved configuration.
|
||||
Future<void> _openSettings() async {
|
||||
final current = _settings ?? await SyncSettings.load();
|
||||
if (!mounted) return;
|
||||
final result = await Navigator.of(context).push<SyncSettings>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => SettingsScreen(initial: current),
|
||||
),
|
||||
);
|
||||
if (result != null && mounted) setState(() => _settings = result);
|
||||
}
|
||||
|
||||
void _openList() {
|
||||
Navigator.of(context).push<void>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => NotesListScreen(repository: widget.repository),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Runs a full sync, routing to settings first if not yet configured.
|
||||
Future<void> _sync() async {
|
||||
final settings = _settings ?? await SyncSettings.load();
|
||||
if (!settings.isConfigured) {
|
||||
_showSnack('Add a GitHub token in settings to enable sync');
|
||||
await _openSettings();
|
||||
return;
|
||||
}
|
||||
setState(() => _syncing = true);
|
||||
final client = GitHubClient(
|
||||
owner: settings.owner,
|
||||
repo: settings.repo,
|
||||
token: settings.token,
|
||||
);
|
||||
try {
|
||||
final result = await _syncService.sync(widget.repository, client);
|
||||
_showSnack('Synced: merged ${result.mergedDevices} device(s)');
|
||||
} catch (e) {
|
||||
_showSnack('Sync failed: $e');
|
||||
} finally {
|
||||
client.close();
|
||||
if (mounted) setState(() => _syncing = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _showSnack(String message) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message), duration: const Duration(seconds: 2)),
|
||||
);
|
||||
}
|
||||
|
||||
/// Persists the current text on every change. Creates the note row on
|
||||
/// the first non-empty keystroke so empty drafts never hit storage.
|
||||
Future<void> _onChanged(String text) async {
|
||||
if (_draftId == null) {
|
||||
if (text.isEmpty) return;
|
||||
_draftId = _uuid.v4();
|
||||
_draftCreatedAt = DateTime.now();
|
||||
}
|
||||
final now = DateTime.now();
|
||||
await widget.repository.upsert(
|
||||
Note(
|
||||
id: _draftId!,
|
||||
text: text,
|
||||
priority: Priority.none,
|
||||
createdAt: _draftCreatedAt!,
|
||||
updatedAt: now,
|
||||
),
|
||||
);
|
||||
if (mounted) setState(() => _lastSavedAt = now);
|
||||
}
|
||||
|
||||
/// Finalises the current idea and resets the field for the next one.
|
||||
void _saveAndReset() {
|
||||
final hadText = _controller.text.trim().isNotEmpty;
|
||||
setState(() {
|
||||
_controller.clear();
|
||||
_draftId = null;
|
||||
_draftCreatedAt = null;
|
||||
_lastSavedAt = null;
|
||||
});
|
||||
_focusNode.requestFocus();
|
||||
if (hadText) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Idea saved locally'),
|
||||
duration: Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Capture'),
|
||||
actions: [
|
||||
// Live count of stored notes, proving local persistence.
|
||||
StreamBuilder<List<Note>>(
|
||||
stream: widget.repository.watchNotes(),
|
||||
builder: (context, snapshot) {
|
||||
final count = snapshot.data?.length ?? 0;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 4),
|
||||
child: Center(child: Text('$count saved')),
|
||||
);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Sync',
|
||||
onPressed: _syncing ? null : _sync,
|
||||
icon: _syncing
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.sync),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Notes',
|
||||
onPressed: _openList,
|
||||
icon: const Icon(Icons.list),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Sync settings',
|
||||
onPressed: _openSettings,
|
||||
icon: const Icon(Icons.settings),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
focusNode: _focusNode,
|
||||
autofocus: true,
|
||||
maxLines: null,
|
||||
expands: true,
|
||||
textAlignVertical: TextAlignVertical.top,
|
||||
keyboardType: TextInputType.multiline,
|
||||
style: theme.textTheme.bodyLarge,
|
||||
decoration: const InputDecoration(
|
||||
border: InputBorder.none,
|
||||
hintText: 'Write your idea…',
|
||||
),
|
||||
onChanged: _onChanged,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_lastSavedAt == null
|
||||
? 'Autosaves as you type'
|
||||
: 'Saved locally at ${_formatTime(_lastSavedAt!)}',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: _saveAndReset,
|
||||
icon: const Icon(Icons.check),
|
||||
label: const Text('Save'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Formats a timestamp as zero-padded HH:mm:ss for the save indicator.
|
||||
String _formatTime(DateTime t) {
|
||||
String two(int n) => n.toString().padLeft(2, '0');
|
||||
return '${two(t.hour)}:${two(t.minute)}:${two(t.second)}';
|
||||
}
|
||||
}
|
||||
56
lib/ui/notes_list_screen.dart
Normal file
56
lib/ui/notes_list_screen.dart
Normal file
@ -0,0 +1,56 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../data/note.dart';
|
||||
import '../data/note_repository.dart';
|
||||
|
||||
/// Barebones list of all stored/synced notes, newest-modified first.
|
||||
///
|
||||
/// Deliberately minimal for now (the rich history view with filter/sort by
|
||||
/// created/modified/alphabetical/priority is deferred). Its job today is to
|
||||
/// show that synced items actually landed locally.
|
||||
class NotesListScreen extends StatelessWidget {
|
||||
const NotesListScreen({required this.repository, super.key});
|
||||
|
||||
final NoteRepository repository;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Notes')),
|
||||
body: StreamBuilder<List<Note>>(
|
||||
stream: repository.watchNotes(),
|
||||
builder: (context, snapshot) {
|
||||
final notes = snapshot.data ?? const <Note>[];
|
||||
if (notes.isEmpty) {
|
||||
return const Center(child: Text('No notes yet'));
|
||||
}
|
||||
return ListView.separated(
|
||||
itemCount: notes.length,
|
||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||
itemBuilder: (context, i) {
|
||||
final note = notes[i];
|
||||
final firstLine = note.text.split('\n').first;
|
||||
return ListTile(
|
||||
title: Text(
|
||||
firstLine.isEmpty ? '(empty)' : firstLine,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text('edited ${_relative(note.updatedAt)}'),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Compact relative time like "2m ago" for the list subtitle.
|
||||
String _relative(DateTime t) {
|
||||
final d = DateTime.now().difference(t);
|
||||
if (d.inMinutes < 1) return 'just now';
|
||||
if (d.inHours < 1) return '${d.inMinutes}m ago';
|
||||
if (d.inDays < 1) return '${d.inHours}h ago';
|
||||
return '${d.inDays}d ago';
|
||||
}
|
||||
}
|
||||
278
lib/ui/settings_screen.dart
Normal file
278
lib/ui/settings_screen.dart
Normal file
@ -0,0 +1,278 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../sync/github_client.dart';
|
||||
import '../sync/github_device_auth.dart';
|
||||
import '../sync/sync_settings.dart';
|
||||
|
||||
/// Settings screen for GitHub sync configuration.
|
||||
///
|
||||
/// Primary path: the "Connect GitHub" button runs the OAuth **device flow**
|
||||
/// (authorize in a browser, no token pasting). The manual token field
|
||||
/// remains as a fallback.
|
||||
class SettingsScreen extends StatefulWidget {
|
||||
const SettingsScreen({required this.initial, super.key});
|
||||
|
||||
final SyncSettings initial;
|
||||
|
||||
@override
|
||||
State<SettingsScreen> createState() => _SettingsScreenState();
|
||||
}
|
||||
|
||||
class _SettingsScreenState extends State<SettingsScreen> {
|
||||
late final TextEditingController _owner =
|
||||
TextEditingController(text: widget.initial.owner);
|
||||
late final TextEditingController _repo =
|
||||
TextEditingController(text: widget.initial.repo);
|
||||
late final TextEditingController _token =
|
||||
TextEditingController(text: widget.initial.token);
|
||||
late final TextEditingController _clientId =
|
||||
TextEditingController(text: widget.initial.clientId);
|
||||
|
||||
bool _testing = false;
|
||||
String? _status;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_owner.dispose();
|
||||
_repo.dispose();
|
||||
_token.dispose();
|
||||
_clientId.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
SyncSettings get _current => SyncSettings(
|
||||
owner: _owner.text.trim(),
|
||||
repo: _repo.text.trim(),
|
||||
token: _token.text.trim(),
|
||||
clientId: _clientId.text.trim(),
|
||||
);
|
||||
|
||||
/// Runs the OAuth device flow and, on success, fills in the token field.
|
||||
Future<void> _connectGitHub() async {
|
||||
final clientId = _clientId.text.trim();
|
||||
if (clientId.isEmpty) {
|
||||
setState(() => _status = 'Enter the OAuth App client id first.');
|
||||
return;
|
||||
}
|
||||
final auth = GitHubDeviceAuth(clientId: clientId);
|
||||
try {
|
||||
final device = await auth.requestDeviceCode();
|
||||
if (!mounted) return;
|
||||
final token = await showDialog<String>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => _DeviceCodeDialog(device: device, auth: auth),
|
||||
);
|
||||
if (token != null && token.isNotEmpty) {
|
||||
setState(() {
|
||||
_token.text = token;
|
||||
_status = 'Connected via GitHub. Token saved on Save.';
|
||||
});
|
||||
await _current.save();
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _status = 'Could not start device flow: $e');
|
||||
} finally {
|
||||
auth.close();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _test() async {
|
||||
setState(() {
|
||||
_testing = true;
|
||||
_status = null;
|
||||
});
|
||||
final s = _current;
|
||||
final client = GitHubClient(owner: s.owner, repo: s.repo, token: s.token);
|
||||
try {
|
||||
final ok = await client.canAccessRepo();
|
||||
setState(() => _status = ok
|
||||
? 'Connected — repo is reachable.'
|
||||
: 'Could not access ${s.owner}/${s.repo}. Check token scope.');
|
||||
} catch (e) {
|
||||
setState(() => _status = 'Error: $e');
|
||||
} finally {
|
||||
client.close();
|
||||
if (mounted) setState(() => _testing = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
final s = _current;
|
||||
await s.save();
|
||||
if (mounted) Navigator.of(context).pop(s);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Sync settings')),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
TextField(
|
||||
controller: _owner,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'GitHub owner',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _repo,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Repository name',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text('Connect with GitHub',
|
||||
style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _clientId,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'OAuth App client id',
|
||||
helperText: 'From your GitHub OAuth App (device flow enabled)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
onPressed: _connectGitHub,
|
||||
icon: const Icon(Icons.login),
|
||||
label: const Text('Connect GitHub'),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Divider(),
|
||||
const SizedBox(height: 8),
|
||||
Text('Or paste a token (fallback)',
|
||||
style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _token,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Access token (fine-grained PAT)',
|
||||
helperText: 'Contents: read/write on the sync repo only',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: _testing ? null : _test,
|
||||
icon: _testing
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.wifi_tethering),
|
||||
label: const Text('Test connection'),
|
||||
),
|
||||
const Spacer(),
|
||||
FilledButton.icon(
|
||||
onPressed: _save,
|
||||
icon: const Icon(Icons.save),
|
||||
label: const Text('Save'),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_status != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(_status!, style: Theme.of(context).textTheme.bodyMedium),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Dialog shown during the device flow: displays the user code, opens the
|
||||
/// verification page, and polls until authorized — popping the token (or
|
||||
/// null if cancelled / failed).
|
||||
class _DeviceCodeDialog extends StatefulWidget {
|
||||
const _DeviceCodeDialog({required this.device, required this.auth});
|
||||
|
||||
final DeviceCodeResponse device;
|
||||
final GitHubDeviceAuth auth;
|
||||
|
||||
@override
|
||||
State<_DeviceCodeDialog> createState() => _DeviceCodeDialogState();
|
||||
}
|
||||
|
||||
class _DeviceCodeDialogState extends State<_DeviceCodeDialog> {
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_poll();
|
||||
}
|
||||
|
||||
Future<void> _poll() async {
|
||||
try {
|
||||
final token = await widget.auth.pollForToken(widget.device);
|
||||
if (mounted) Navigator.of(context).pop(token);
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _error = '$e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openPage() async {
|
||||
await Clipboard.setData(ClipboardData(text: widget.device.userCode));
|
||||
await launchUrl(
|
||||
Uri.parse(widget.device.verificationUri),
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Authorize on GitHub'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Enter this code on GitHub:'),
|
||||
const SizedBox(height: 8),
|
||||
SelectableText(
|
||||
widget.device.userCode,
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (_error == null)
|
||||
const Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
SizedBox(width: 12),
|
||||
Expanded(child: Text('Waiting for authorization…')),
|
||||
],
|
||||
)
|
||||
else
|
||||
Text(_error!, style: const TextStyle(color: Colors.red)),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton.icon(
|
||||
onPressed: _openPage,
|
||||
icon: const Icon(Icons.open_in_new),
|
||||
label: const Text('Open GitHub & copy code'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
1
linux/.gitignore
vendored
Normal file
1
linux/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
flutter/ephemeral
|
||||
128
linux/CMakeLists.txt
Normal file
128
linux/CMakeLists.txt
Normal file
@ -0,0 +1,128 @@
|
||||
# Project-level configuration.
|
||||
cmake_minimum_required(VERSION 3.13)
|
||||
project(runner LANGUAGES CXX)
|
||||
|
||||
# The name of the executable created for the application. Change this to change
|
||||
# the on-disk name of your application.
|
||||
set(BINARY_NAME "todo")
|
||||
# The unique GTK application identifier for this application. See:
|
||||
# https://wiki.gnome.org/HowDoI/ChooseApplicationID
|
||||
set(APPLICATION_ID "dev.kuhy.todo")
|
||||
|
||||
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
|
||||
# versions of CMake.
|
||||
cmake_policy(SET CMP0063 NEW)
|
||||
|
||||
# Load bundled libraries from the lib/ directory relative to the binary.
|
||||
set(CMAKE_INSTALL_RPATH "$ORIGIN/lib")
|
||||
|
||||
# Root filesystem for cross-building.
|
||||
if(FLUTTER_TARGET_PLATFORM_SYSROOT)
|
||||
set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT})
|
||||
set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT})
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
|
||||
endif()
|
||||
|
||||
# Define build configuration options.
|
||||
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
|
||||
set(CMAKE_BUILD_TYPE "Debug" CACHE
|
||||
STRING "Flutter build mode" FORCE)
|
||||
set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS
|
||||
"Debug" "Profile" "Release")
|
||||
endif()
|
||||
|
||||
# Compilation settings that should be applied to most targets.
|
||||
#
|
||||
# Be cautious about adding new options here, as plugins use this function by
|
||||
# default. In most cases, you should add new options to specific targets instead
|
||||
# of modifying this function.
|
||||
function(APPLY_STANDARD_SETTINGS TARGET)
|
||||
target_compile_features(${TARGET} PUBLIC cxx_std_14)
|
||||
target_compile_options(${TARGET} PRIVATE -Wall -Werror)
|
||||
target_compile_options(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:-O3>")
|
||||
target_compile_definitions(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:NDEBUG>")
|
||||
endfunction()
|
||||
|
||||
# Flutter library and tool build rules.
|
||||
set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter")
|
||||
add_subdirectory(${FLUTTER_MANAGED_DIR})
|
||||
|
||||
# System-level dependencies.
|
||||
find_package(PkgConfig REQUIRED)
|
||||
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
|
||||
|
||||
# Application build; see runner/CMakeLists.txt.
|
||||
add_subdirectory("runner")
|
||||
|
||||
# Run the Flutter tool portions of the build. This must not be removed.
|
||||
add_dependencies(${BINARY_NAME} flutter_assemble)
|
||||
|
||||
# Only the install-generated bundle's copy of the executable will launch
|
||||
# correctly, since the resources must in the right relative locations. To avoid
|
||||
# people trying to run the unbundled copy, put it in a subdirectory instead of
|
||||
# the default top-level location.
|
||||
set_target_properties(${BINARY_NAME}
|
||||
PROPERTIES
|
||||
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run"
|
||||
)
|
||||
|
||||
|
||||
# Generated plugin build rules, which manage building the plugins and adding
|
||||
# them to the application.
|
||||
include(flutter/generated_plugins.cmake)
|
||||
|
||||
|
||||
# === Installation ===
|
||||
# By default, "installing" just makes a relocatable bundle in the build
|
||||
# directory.
|
||||
set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle")
|
||||
if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT)
|
||||
set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE)
|
||||
endif()
|
||||
|
||||
# Start with a clean build bundle directory every time.
|
||||
install(CODE "
|
||||
file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\")
|
||||
" COMPONENT Runtime)
|
||||
|
||||
set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data")
|
||||
set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib")
|
||||
|
||||
install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}"
|
||||
COMPONENT Runtime)
|
||||
|
||||
install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}"
|
||||
COMPONENT Runtime)
|
||||
|
||||
install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||
COMPONENT Runtime)
|
||||
|
||||
foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES})
|
||||
install(FILES "${bundled_library}"
|
||||
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||
COMPONENT Runtime)
|
||||
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
|
||||
# from a previous install.
|
||||
set(FLUTTER_ASSET_DIR_NAME "flutter_assets")
|
||||
install(CODE "
|
||||
file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\")
|
||||
" COMPONENT Runtime)
|
||||
install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}"
|
||||
DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime)
|
||||
|
||||
# Install the AOT library on non-Debug builds only.
|
||||
if(NOT CMAKE_BUILD_TYPE MATCHES "Debug")
|
||||
install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||
COMPONENT Runtime)
|
||||
endif()
|
||||
88
linux/flutter/CMakeLists.txt
Normal file
88
linux/flutter/CMakeLists.txt
Normal file
@ -0,0 +1,88 @@
|
||||
# This file controls Flutter-level build steps. It should not be edited.
|
||||
cmake_minimum_required(VERSION 3.10)
|
||||
|
||||
set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral")
|
||||
|
||||
# Configuration provided via flutter tool.
|
||||
include(${EPHEMERAL_DIR}/generated_config.cmake)
|
||||
|
||||
# TODO: Move the rest of this into files in ephemeral. See
|
||||
# https://github.com/flutter/flutter/issues/57146.
|
||||
|
||||
# Serves the same purpose as list(TRANSFORM ... PREPEND ...),
|
||||
# which isn't available in 3.10.
|
||||
function(list_prepend LIST_NAME PREFIX)
|
||||
set(NEW_LIST "")
|
||||
foreach(element ${${LIST_NAME}})
|
||||
list(APPEND NEW_LIST "${PREFIX}${element}")
|
||||
endforeach(element)
|
||||
set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
# === Flutter Library ===
|
||||
# System-level dependencies.
|
||||
find_package(PkgConfig REQUIRED)
|
||||
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
|
||||
pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0)
|
||||
pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0)
|
||||
|
||||
set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so")
|
||||
|
||||
# Published to parent scope for install step.
|
||||
set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE)
|
||||
set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE)
|
||||
set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE)
|
||||
set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE)
|
||||
|
||||
list(APPEND FLUTTER_LIBRARY_HEADERS
|
||||
"fl_basic_message_channel.h"
|
||||
"fl_binary_codec.h"
|
||||
"fl_binary_messenger.h"
|
||||
"fl_dart_project.h"
|
||||
"fl_engine.h"
|
||||
"fl_json_message_codec.h"
|
||||
"fl_json_method_codec.h"
|
||||
"fl_message_codec.h"
|
||||
"fl_method_call.h"
|
||||
"fl_method_channel.h"
|
||||
"fl_method_codec.h"
|
||||
"fl_method_response.h"
|
||||
"fl_plugin_registrar.h"
|
||||
"fl_plugin_registry.h"
|
||||
"fl_standard_message_codec.h"
|
||||
"fl_standard_method_codec.h"
|
||||
"fl_string_codec.h"
|
||||
"fl_value.h"
|
||||
"fl_view.h"
|
||||
"flutter_linux.h"
|
||||
)
|
||||
list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/")
|
||||
add_library(flutter INTERFACE)
|
||||
target_include_directories(flutter INTERFACE
|
||||
"${EPHEMERAL_DIR}"
|
||||
)
|
||||
target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}")
|
||||
target_link_libraries(flutter INTERFACE
|
||||
PkgConfig::GTK
|
||||
PkgConfig::GLIB
|
||||
PkgConfig::GIO
|
||||
)
|
||||
add_dependencies(flutter flutter_assemble)
|
||||
|
||||
# === Flutter tool backend ===
|
||||
# _phony_ is a non-existent file to force this command to run every time,
|
||||
# since currently there's no way to get a full input/output list from the
|
||||
# flutter tool.
|
||||
add_custom_command(
|
||||
OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS}
|
||||
${CMAKE_CURRENT_BINARY_DIR}/_phony_
|
||||
COMMAND ${CMAKE_COMMAND} -E env
|
||||
${FLUTTER_TOOL_ENVIRONMENT}
|
||||
"${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh"
|
||||
${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE}
|
||||
VERBATIM
|
||||
)
|
||||
add_custom_target(flutter_assemble DEPENDS
|
||||
"${FLUTTER_LIBRARY}"
|
||||
${FLUTTER_LIBRARY_HEADERS}
|
||||
)
|
||||
15
linux/flutter/generated_plugin_registrant.cc
Normal file
15
linux/flutter/generated_plugin_registrant.cc
Normal file
@ -0,0 +1,15 @@
|
||||
//
|
||||
// Generated file. Do not edit.
|
||||
//
|
||||
|
||||
// clang-format off
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||
|
||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
||||
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
||||
}
|
||||
15
linux/flutter/generated_plugin_registrant.h
Normal file
15
linux/flutter/generated_plugin_registrant.h
Normal file
@ -0,0 +1,15 @@
|
||||
//
|
||||
// Generated file. Do not edit.
|
||||
//
|
||||
|
||||
// clang-format off
|
||||
|
||||
#ifndef GENERATED_PLUGIN_REGISTRANT_
|
||||
#define GENERATED_PLUGIN_REGISTRANT_
|
||||
|
||||
#include <flutter_linux/flutter_linux.h>
|
||||
|
||||
// Registers Flutter plugins.
|
||||
void fl_register_plugins(FlPluginRegistry* registry);
|
||||
|
||||
#endif // GENERATED_PLUGIN_REGISTRANT_
|
||||
25
linux/flutter/generated_plugins.cmake
Normal file
25
linux/flutter/generated_plugins.cmake
Normal file
@ -0,0 +1,25 @@
|
||||
#
|
||||
# Generated file, do not edit.
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
url_launcher_linux
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
jni
|
||||
)
|
||||
|
||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||
|
||||
foreach(plugin ${FLUTTER_PLUGIN_LIST})
|
||||
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin})
|
||||
target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin)
|
||||
list(APPEND PLUGIN_BUNDLED_LIBRARIES $<TARGET_FILE:${plugin}_plugin>)
|
||||
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries})
|
||||
endforeach(plugin)
|
||||
|
||||
foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST})
|
||||
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin})
|
||||
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries})
|
||||
endforeach(ffi_plugin)
|
||||
26
linux/runner/CMakeLists.txt
Normal file
26
linux/runner/CMakeLists.txt
Normal file
@ -0,0 +1,26 @@
|
||||
cmake_minimum_required(VERSION 3.13)
|
||||
project(runner LANGUAGES CXX)
|
||||
|
||||
# Define the application target. To change its name, change BINARY_NAME in the
|
||||
# top-level CMakeLists.txt, 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 preprocessor definitions for the application ID.
|
||||
add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}")
|
||||
|
||||
# Add dependency libraries. Add any application-specific dependencies here.
|
||||
target_link_libraries(${BINARY_NAME} PRIVATE flutter)
|
||||
target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK)
|
||||
|
||||
target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}")
|
||||
6
linux/runner/main.cc
Normal file
6
linux/runner/main.cc
Normal file
@ -0,0 +1,6 @@
|
||||
#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);
|
||||
}
|
||||
148
linux/runner/my_application.cc
Normal file
148
linux/runner/my_application.cc
Normal file
@ -0,0 +1,148 @@
|
||||
#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)
|
||||
|
||||
// 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.
|
||||
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, "todo");
|
||||
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, "todo");
|
||||
}
|
||||
|
||||
gtk_window_set_default_size(window, 1280, 720);
|
||||
|
||||
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);
|
||||
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_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));
|
||||
|
||||
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 GApplication::startup.
|
||||
static void my_application_startup(GApplication* application) {
|
||||
// MyApplication* self = MY_APPLICATION(object);
|
||||
|
||||
// Perform any actions required at application startup.
|
||||
|
||||
G_APPLICATION_CLASS(my_application_parent_class)->startup(application);
|
||||
}
|
||||
|
||||
// Implements GApplication::shutdown.
|
||||
static void my_application_shutdown(GApplication* application) {
|
||||
// MyApplication* self = MY_APPLICATION(object);
|
||||
|
||||
// Perform any actions required at application shutdown.
|
||||
|
||||
G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application);
|
||||
}
|
||||
|
||||
// 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_APPLICATION_CLASS(klass)->startup = my_application_startup;
|
||||
G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown;
|
||||
G_OBJECT_CLASS(klass)->dispose = my_application_dispose;
|
||||
}
|
||||
|
||||
static void my_application_init(MyApplication* self) {}
|
||||
|
||||
MyApplication* my_application_new() {
|
||||
// Set the program name to the application ID, which helps various systems
|
||||
// like GTK and desktop environments map this running application to its
|
||||
// corresponding .desktop file. This ensures better integration by allowing
|
||||
// the application to be recognized beyond its binary name.
|
||||
g_set_prgname(APPLICATION_ID);
|
||||
|
||||
return MY_APPLICATION(g_object_new(my_application_get_type(),
|
||||
"application-id", APPLICATION_ID, "flags",
|
||||
G_APPLICATION_NON_UNIQUE, nullptr));
|
||||
}
|
||||
21
linux/runner/my_application.h
Normal file
21
linux/runner/my_application.h
Normal file
@ -0,0 +1,21 @@
|
||||
#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_
|
||||
690
pubspec.lock
Normal file
690
pubspec.lock
Normal file
@ -0,0 +1,690 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: args
|
||||
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.7.0"
|
||||
async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: async
|
||||
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.1"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: boolean_selector
|
||||
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: characters
|
||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
charcode:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: charcode
|
||||
sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
clock:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: clock
|
||||
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
code_assets:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: code_assets
|
||||
sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: collection
|
||||
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
crdt:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: crdt
|
||||
sha256: "5709bbb4bb6f1b7e08fa1a3896070adec870ed4029c5b5da6fb30fa3c501cb65"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.1.3"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: crypto
|
||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
cupertino_icons:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: cupertino_icons
|
||||
sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.9"
|
||||
dev_build:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dev_build
|
||||
sha256: "21df3f741f0f5fc7c45f618e68e206b9f021b1adf83db4004dfaa032996cb0c1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.8+2"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fake_async
|
||||
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.3"
|
||||
ffi:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ffi
|
||||
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file
|
||||
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
fixnum:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fixnum
|
||||
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_lints
|
||||
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_web_plugins:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
glob:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: glob
|
||||
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.3"
|
||||
hooks:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: hooks
|
||||
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.2"
|
||||
http:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: http
|
||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.6.0"
|
||||
http_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_parser
|
||||
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
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"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker
|
||||
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "11.0.2"
|
||||
leak_tracker_flutter_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_flutter_testing
|
||||
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.10"
|
||||
leak_tracker_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_testing
|
||||
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
lints:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: lints
|
||||
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.0"
|
||||
logging:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: logging
|
||||
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.19"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.13.0"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.18.0"
|
||||
native_toolchain_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: native_toolchain_c
|
||||
sha256: f59351d28f49520cd3a74eb1f41c5f19ae15e53c65a3231d14af672e46510a96
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.19.1"
|
||||
objective_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: objective_c
|
||||
sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.4.1"
|
||||
package_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_config
|
||||
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
path:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: path
|
||||
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
path_provider:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: path_provider
|
||||
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.5"
|
||||
path_provider_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_android
|
||||
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.1"
|
||||
path_provider_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_foundation
|
||||
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.0"
|
||||
path_provider_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_linux
|
||||
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.1"
|
||||
path_provider_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_platform_interface
|
||||
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
path_provider_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_windows
|
||||
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: platform
|
||||
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.6"
|
||||
plugin_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: plugin_platform_interface
|
||||
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
pool:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pool
|
||||
sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.2"
|
||||
process_run:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: process_run
|
||||
sha256: "3af4220bdee974fd6305caab920c8af07cd846696f825e6e84fb4e123b13fde9"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.4"
|
||||
pub_semver:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pub_semver
|
||||
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
record_use:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: record_use
|
||||
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.0"
|
||||
shared_preferences:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: shared_preferences
|
||||
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.5"
|
||||
shared_preferences_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_android
|
||||
sha256: "93ae5884a9df5d3bb696825bceb3a17590754548b5d740eba51500afc8d088f5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.26"
|
||||
shared_preferences_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_foundation
|
||||
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.6"
|
||||
shared_preferences_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_linux
|
||||
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
shared_preferences_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_platform_interface
|
||||
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.2"
|
||||
shared_preferences_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_web
|
||||
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.3"
|
||||
shared_preferences_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_windows
|
||||
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
source_span:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_span
|
||||
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.2"
|
||||
sqflite_common:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_common
|
||||
sha256: "5bf6a55c166e73bf651ba7ec3ed486e577620e3dc8f3a9c6a258a8031b624590"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.11"
|
||||
sqflite_common_ffi:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: sqflite_common_ffi
|
||||
sha256: "5ccd38136edb9beb3213f6927775d52db70dfdadcdb28dad1f625ca9f2b9824f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.2"
|
||||
sqflite_common_ffi_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_common_ffi_web
|
||||
sha256: "32dc484518fec883cf0d1aa85c767f7cde6757729480b3d8d20e0fac247ef161"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
sql_crdt:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sql_crdt
|
||||
sha256: db0a4133a63a996d262d7e73b97e6ddc4550f4e3bd2449930627472d622bd946
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.3"
|
||||
sqlite3:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqlite3
|
||||
sha256: "37356bcb56ce0d9404d602c41e4bdb7765e7e9732a3e47adb3d98c556a6abdad"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.3.3"
|
||||
sqlite_crdt:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: sqlite_crdt
|
||||
sha256: "11ce9f5b583a7c3c09f806223e2a6b1733daab45214a2b8f7ab250887382987e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.4"
|
||||
sqlparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqlparser
|
||||
sha256: "57090342af1ce32bb499aa641f4ecdd2d6231b9403cea537ac059e803cc20d67"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.41.2"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stack_trace
|
||||
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.12.1"
|
||||
stream_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stream_channel
|
||||
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
string_scanner:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: string_scanner
|
||||
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
synchronized:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: synchronized
|
||||
sha256: "93b153dcb6a26dcddee6ca087dd634b53e38c10b5aa163e8e49501a776456153"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.4.1"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: term_glyph
|
||||
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.2"
|
||||
test_api:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.11"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: typed_data
|
||||
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
url_launcher:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: url_launcher
|
||||
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.2"
|
||||
url_launcher_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_android
|
||||
sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.32"
|
||||
url_launcher_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_ios
|
||||
sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.4.1"
|
||||
url_launcher_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_linux
|
||||
sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.2"
|
||||
url_launcher_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_macos
|
||||
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.5"
|
||||
url_launcher_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_platform_interface
|
||||
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.2"
|
||||
url_launcher_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_web
|
||||
sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.3"
|
||||
url_launcher_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_windows
|
||||
sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.5"
|
||||
uuid:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: uuid
|
||||
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.5.3"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_math
|
||||
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
vm_service:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vm_service
|
||||
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.2.0"
|
||||
web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web
|
||||
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xdg_directories
|
||||
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: yaml
|
||||
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.12.2 <4.0.0"
|
||||
flutter: ">=3.44.0"
|
||||
97
pubspec.yaml
Normal file
97
pubspec.yaml
Normal file
@ -0,0 +1,97 @@
|
||||
name: todo
|
||||
description: "A new Flutter project."
|
||||
# The following line prevents the package from being accidentally published to
|
||||
# pub.dev using `flutter pub publish`. This is preferred for private packages.
|
||||
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
|
||||
# The following defines the version and build number for your application.
|
||||
# A version number is three numbers separated by dots, like 1.2.43
|
||||
# followed by an optional build number separated by a +.
|
||||
# Both the version and the builder number may be overridden in flutter
|
||||
# build by specifying --build-name and --build-number, respectively.
|
||||
# In Android, build-name is used as versionName while build-number used as versionCode.
|
||||
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
|
||||
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
|
||||
# Read more about iOS versioning at
|
||||
# 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
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 1.0.0+1
|
||||
|
||||
environment:
|
||||
sdk: ^3.12.2
|
||||
|
||||
# Dependencies specify other packages that your package needs in order to work.
|
||||
# To automatically upgrade your package dependencies to the latest versions
|
||||
# consider running `flutter pub upgrade --major-versions`. Alternatively,
|
||||
# dependencies can be manually updated by changing the version numbers below to
|
||||
# the latest version available on pub.dev. To see which dependencies have newer
|
||||
# versions available, run `flutter pub outdated`.
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
# The following adds the Cupertino Icons font to your application.
|
||||
# Use with the CupertinoIcons class for iOS style icons.
|
||||
cupertino_icons: ^1.0.8
|
||||
sqlite_crdt: ^3.0.4
|
||||
path_provider: ^2.1.5
|
||||
path: ^1.9.1
|
||||
uuid: ^4.5.3
|
||||
sqflite_common_ffi: ^2.4.2
|
||||
http: ^1.6.0
|
||||
shared_preferences: ^2.5.5
|
||||
url_launcher: ^6.3.2
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
||||
# The "flutter_lints" package below contains a set of recommended lints to
|
||||
# encourage good coding practices. The lint set provided by the package is
|
||||
# activated in the `analysis_options.yaml` file located at the root of your
|
||||
# package. See that file for information about deactivating specific lint
|
||||
# rules and activating additional ones.
|
||||
flutter_lints: ^6.0.0
|
||||
|
||||
# For information on the generic Dart part of this file, see the
|
||||
# following page: https://dart.dev/tools/pub/pubspec
|
||||
|
||||
# The following section is specific to Flutter packages.
|
||||
flutter:
|
||||
|
||||
# The following line ensures that the Material Icons font is
|
||||
# included with your application, so that you can use the icons in
|
||||
# the material Icons class.
|
||||
uses-material-design: true
|
||||
|
||||
# To add assets to your application, add an assets section, like this:
|
||||
# assets:
|
||||
# - images/a_dot_burr.jpeg
|
||||
# - images/a_dot_ham.jpeg
|
||||
|
||||
# An image asset can refer to one or more resolution-specific "variants", see
|
||||
# https://flutter.dev/to/resolution-aware-images
|
||||
|
||||
# For details regarding adding assets from package dependencies, see
|
||||
# https://flutter.dev/to/asset-from-package
|
||||
|
||||
# To add custom fonts to your application, add a fonts section here,
|
||||
# in this "flutter" section. Each entry in this list should have a
|
||||
# "family" key with the font family name, and a "fonts" key with a
|
||||
# list giving the asset and other descriptors for the font. For
|
||||
# example:
|
||||
# fonts:
|
||||
# - family: Schyler
|
||||
# fonts:
|
||||
# - asset: fonts/Schyler-Regular.ttf
|
||||
# - asset: fonts/Schyler-Italic.ttf
|
||||
# style: italic
|
||||
# - family: Trajan Pro
|
||||
# fonts:
|
||||
# - asset: fonts/TrajanPro.ttf
|
||||
# - asset: fonts/TrajanPro_Bold.ttf
|
||||
# weight: 700
|
||||
#
|
||||
# For details regarding fonts from package dependencies,
|
||||
# see https://flutter.dev/to/font-from-package
|
||||
89
test/github_device_auth_test.dart
Normal file
89
test/github_device_auth_test.dart
Normal file
@ -0,0 +1,89 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:todo/sync/github_device_auth.dart';
|
||||
|
||||
/// Builds an auth instance whose polls resolve instantly (no real waiting).
|
||||
GitHubDeviceAuth authWith(http.Client client) => GitHubDeviceAuth(
|
||||
clientId: 'test-client-id',
|
||||
httpClient: client,
|
||||
delay: (_) => Future<void>.value(),
|
||||
);
|
||||
|
||||
const _device = DeviceCodeResponse(
|
||||
deviceCode: 'dev-123',
|
||||
userCode: 'WXYZ-1234',
|
||||
verificationUri: 'https://github.com/login/device',
|
||||
interval: 1,
|
||||
expiresIn: 900,
|
||||
);
|
||||
|
||||
void main() {
|
||||
test('requestDeviceCode parses the device + user code', () async {
|
||||
final client = MockClient((req) async {
|
||||
expect(req.url.toString(), contains('login/device/code'));
|
||||
expect(req.bodyFields['client_id'], 'test-client-id');
|
||||
expect(req.bodyFields['scope'], 'repo');
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'device_code': 'dev-123',
|
||||
'user_code': 'WXYZ-1234',
|
||||
'verification_uri': 'https://github.com/login/device',
|
||||
'interval': 5,
|
||||
'expires_in': 900,
|
||||
}),
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
final res = await authWith(client).requestDeviceCode();
|
||||
expect(res.deviceCode, 'dev-123');
|
||||
expect(res.userCode, 'WXYZ-1234');
|
||||
expect(res.verificationUri, 'https://github.com/login/device');
|
||||
});
|
||||
|
||||
test('pollForToken returns the token after authorization_pending', () async {
|
||||
var calls = 0;
|
||||
final client = MockClient((req) async {
|
||||
calls++;
|
||||
// Pending on the first two polls, then success.
|
||||
if (calls < 3) {
|
||||
return http.Response(jsonEncode({'error': 'authorization_pending'}), 200);
|
||||
}
|
||||
return http.Response(
|
||||
jsonEncode({'access_token': 'gho_abc', 'token_type': 'bearer'}), 200);
|
||||
});
|
||||
|
||||
final token = await authWith(client).pollForToken(_device);
|
||||
expect(token, 'gho_abc');
|
||||
expect(calls, 3);
|
||||
});
|
||||
|
||||
test('pollForToken obeys slow_down and still succeeds', () async {
|
||||
var calls = 0;
|
||||
final client = MockClient((req) async {
|
||||
calls++;
|
||||
if (calls == 1) {
|
||||
return http.Response(
|
||||
jsonEncode({'error': 'slow_down', 'interval': 1}), 200);
|
||||
}
|
||||
return http.Response(jsonEncode({'access_token': 'gho_xyz'}), 200);
|
||||
});
|
||||
|
||||
final token = await authWith(client).pollForToken(_device);
|
||||
expect(token, 'gho_xyz');
|
||||
});
|
||||
|
||||
test('pollForToken throws on access_denied', () async {
|
||||
final client = MockClient((req) async => http.Response(
|
||||
jsonEncode({'error': 'access_denied', 'error_description': 'no'}), 200));
|
||||
|
||||
expect(
|
||||
() => authWith(client).pollForToken(_device),
|
||||
throwsA(isA<DeviceAuthException>()
|
||||
.having((e) => e.code, 'code', 'access_denied')),
|
||||
);
|
||||
});
|
||||
}
|
||||
61
test/note_repository_test.dart
Normal file
61
test/note_repository_test.dart
Normal file
@ -0,0 +1,61 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
import 'package:todo/data/note.dart';
|
||||
import 'package:todo/data/note_repository.dart';
|
||||
|
||||
void main() {
|
||||
setUpAll(sqfliteFfiInit);
|
||||
|
||||
Note note(String id, String text, {Priority priority = Priority.none}) {
|
||||
final now = DateTime.now();
|
||||
return Note(
|
||||
id: id,
|
||||
text: text,
|
||||
priority: priority,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
);
|
||||
}
|
||||
|
||||
test('upsert then list returns the note', () async {
|
||||
final repo = await NoteRepository.openInMemory();
|
||||
addTearDown(repo.close);
|
||||
|
||||
await repo.upsert(note('a', 'first idea'));
|
||||
final notes = await repo.listNotes();
|
||||
|
||||
expect(notes, hasLength(1));
|
||||
expect(notes.single.text, 'first idea');
|
||||
});
|
||||
|
||||
test('deleted notes are excluded from reads (tombstone filter)', () async {
|
||||
final repo = await NoteRepository.openInMemory();
|
||||
addTearDown(repo.close);
|
||||
|
||||
await repo.upsert(note('a', 'keep me'));
|
||||
await repo.upsert(note('b', 'delete me'));
|
||||
await repo.delete('b');
|
||||
|
||||
final notes = await repo.listNotes();
|
||||
expect(notes, hasLength(1));
|
||||
expect(notes.single.text, 'keep me');
|
||||
|
||||
// The tombstone must survive in the changeset so the deletion syncs.
|
||||
final changeset = await repo.getChangeset();
|
||||
final rows = changeset['notes']!;
|
||||
final deleted = rows.firstWhere((r) => r['id'] == 'b');
|
||||
expect(deleted['is_deleted'], 1);
|
||||
});
|
||||
|
||||
test('priority sort orders highest first', () async {
|
||||
final repo = await NoteRepository.openInMemory();
|
||||
addTearDown(repo.close);
|
||||
|
||||
await repo.upsert(note('a', 'low', priority: Priority.low));
|
||||
await repo.upsert(note('b', 'high', priority: Priority.high));
|
||||
|
||||
final notes = await repo.listNotes(sort: NoteSort.priorityDesc);
|
||||
expect(notes.first.text, 'high');
|
||||
expect(notes.last.text, 'low');
|
||||
});
|
||||
}
|
||||
93
tool/sync_smoke.dart
Normal file
93
tool/sync_smoke.dart
Normal file
@ -0,0 +1,93 @@
|
||||
// Headless end-to-end proof of the sync engine against a REAL GitHub repo.
|
||||
//
|
||||
// Simulates two devices (A and B), each creating a note offline, then syncs
|
||||
// them through the repo and asserts both devices converge to both notes.
|
||||
// Cleans up its throwaway changeset files afterwards so the real
|
||||
// `changesets/` directory is never touched.
|
||||
//
|
||||
// Run: GH_TOKEN=$(gh auth token) dart run tool/sync_smoke.dart
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
import 'package:todo/data/note.dart';
|
||||
import 'package:todo/data/note_repository.dart';
|
||||
import 'package:todo/sync/github_client.dart';
|
||||
import 'package:todo/sync/sync_service.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
sqfliteFfiInit();
|
||||
|
||||
final token = Platform.environment['GH_TOKEN'];
|
||||
if (token == null || token.isEmpty) {
|
||||
stderr.writeln('Set GH_TOKEN (e.g. GH_TOKEN=\$(gh auth token)).');
|
||||
exit(2);
|
||||
}
|
||||
|
||||
// Throwaway directory so we never pollute the real `changesets/`.
|
||||
const service = SyncService(changesetDir: 'changesets_smoketest');
|
||||
GitHubClient client() => GitHubClient(
|
||||
owner: 'kuhyx',
|
||||
repo: 'todo-sync',
|
||||
token: token,
|
||||
);
|
||||
|
||||
final deviceA = await NoteRepository.openInMemory();
|
||||
final deviceB = await NoteRepository.openInMemory();
|
||||
final stamp = DateTime.now().toIso8601String();
|
||||
|
||||
await _insert(deviceA, 'Idea from device A @ $stamp');
|
||||
await _insert(deviceB, 'Idea from device B @ $stamp');
|
||||
|
||||
stdout.writeln('Device A nodeId: ${deviceA.nodeId}');
|
||||
stdout.writeln('Device B nodeId: ${deviceB.nodeId}');
|
||||
|
||||
// Sync order: A pushes, B pulls A + pushes, A pulls B. Both converge.
|
||||
final ghA = client();
|
||||
final ghB = client();
|
||||
stdout.writeln('A.sync(): ${await service.sync(deviceA, ghA)}');
|
||||
stdout.writeln('B.sync(): ${await service.sync(deviceB, ghB)}');
|
||||
stdout.writeln('A.sync(): ${await service.sync(deviceA, ghA)}');
|
||||
|
||||
final aNotes = (await deviceA.listNotes()).map((n) => n.text).toSet();
|
||||
final bNotes = (await deviceB.listNotes()).map((n) => n.text).toSet();
|
||||
stdout.writeln('\nDevice A sees: $aNotes');
|
||||
stdout.writeln('Device B sees: $bNotes');
|
||||
|
||||
final expected = {
|
||||
'Idea from device A @ $stamp',
|
||||
'Idea from device B @ $stamp',
|
||||
};
|
||||
final converged = aNotes.containsAll(expected) && bNotes.containsAll(expected);
|
||||
|
||||
// Cleanup: remove the throwaway changeset files.
|
||||
final cleanup = client();
|
||||
for (final f in await cleanup.listDirectory('changesets_smoketest')) {
|
||||
await cleanup.deleteFile(f.path, f.sha, message: 'smoke test cleanup');
|
||||
}
|
||||
stdout.writeln('Cleaned up throwaway changeset files.');
|
||||
|
||||
ghA.close();
|
||||
ghB.close();
|
||||
cleanup.close();
|
||||
await deviceA.close();
|
||||
await deviceB.close();
|
||||
|
||||
if (converged) {
|
||||
stdout.writeln('\n✅ PASS: both devices converged to both notes via GitHub.');
|
||||
exit(0);
|
||||
} else {
|
||||
stdout.writeln('\n❌ FAIL: devices did not converge. Expected $expected.');
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _insert(NoteRepository repo, String text) async {
|
||||
final now = DateTime.now();
|
||||
await repo.upsert(Note(
|
||||
id: '${now.microsecondsSinceEpoch}-${text.hashCode}',
|
||||
text: text,
|
||||
priority: Priority.none,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
));
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user