From 0704e44912531f0993b1df298043ffa5e4006393 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Fri, 10 Jul 2026 23:16:11 +0200 Subject: [PATCH] Add wake_alarm_sync phone-side Flutter companion app New Flutter app (com.kuhy.wake_alarm_sync) that sets the phone alarm and writes the wake time to a GitHub Gist for cross-device sync: Gist service, GitHub device-flow auth, sync settings, home/settings screens. Source only; build/.dart_tool/.idea artifacts excluded by the app's .gitignore. Not yet gated by CI (wake-alarm CI is Python-only) and pending on-device verification. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_017RRCdR4D8SaVrN2xraMQxM --- phone_app/.gitignore | 45 ++ phone_app/.metadata | 30 + phone_app/README.md | 17 + phone_app/analysis_options.yaml | 24 + phone_app/android/.gitignore | 14 + phone_app/android/app/build.gradle.kts | 44 ++ .../android/app/src/debug/AndroidManifest.xml | 7 + .../android/app/src/main/AndroidManifest.xml | 39 ++ .../com/kuhy/wake_alarm_sync/MainActivity.kt | 5 + .../res/drawable-v21/launch_background.xml | 12 + .../main/res/drawable/launch_background.xml | 12 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 544 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 442 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 721 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 1031 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 1443 bytes .../app/src/main/res/values-night/styles.xml | 18 + .../app/src/main/res/values/styles.xml | 18 + .../app/src/profile/AndroidManifest.xml | 7 + phone_app/android/build.gradle.kts | 24 + phone_app/android/gradle.properties | 6 + .../gradle/wrapper/gradle-wrapper.properties | 5 + phone_app/android/settings.gradle.kts | 26 + phone_app/lib/main.dart | 26 + phone_app/lib/screens/home_screen.dart | 215 ++++++ phone_app/lib/screens/settings_screen.dart | 262 ++++++++ phone_app/lib/services/gist_service.dart | 87 +++ .../lib/services/github_device_auth.dart | 165 +++++ phone_app/lib/services/sync_settings.dart | 104 +++ phone_app/pubspec.lock | 618 ++++++++++++++++++ phone_app/pubspec.yaml | 26 + phone_app/test/widget_test.dart | 9 + 32 files changed, 1865 insertions(+) create mode 100644 phone_app/.gitignore create mode 100644 phone_app/.metadata create mode 100644 phone_app/README.md create mode 100644 phone_app/analysis_options.yaml create mode 100644 phone_app/android/.gitignore create mode 100644 phone_app/android/app/build.gradle.kts create mode 100644 phone_app/android/app/src/debug/AndroidManifest.xml create mode 100644 phone_app/android/app/src/main/AndroidManifest.xml create mode 100644 phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/MainActivity.kt create mode 100644 phone_app/android/app/src/main/res/drawable-v21/launch_background.xml create mode 100644 phone_app/android/app/src/main/res/drawable/launch_background.xml create mode 100644 phone_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 phone_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 phone_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 phone_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 phone_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 phone_app/android/app/src/main/res/values-night/styles.xml create mode 100644 phone_app/android/app/src/main/res/values/styles.xml create mode 100644 phone_app/android/app/src/profile/AndroidManifest.xml create mode 100644 phone_app/android/build.gradle.kts create mode 100644 phone_app/android/gradle.properties create mode 100644 phone_app/android/gradle/wrapper/gradle-wrapper.properties create mode 100644 phone_app/android/settings.gradle.kts create mode 100644 phone_app/lib/main.dart create mode 100644 phone_app/lib/screens/home_screen.dart create mode 100644 phone_app/lib/screens/settings_screen.dart create mode 100644 phone_app/lib/services/gist_service.dart create mode 100644 phone_app/lib/services/github_device_auth.dart create mode 100644 phone_app/lib/services/sync_settings.dart create mode 100644 phone_app/pubspec.lock create mode 100644 phone_app/pubspec.yaml create mode 100644 phone_app/test/widget_test.dart diff --git a/phone_app/.gitignore b/phone_app/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/phone_app/.gitignore @@ -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 diff --git a/phone_app/.metadata b/phone_app/.metadata new file mode 100644 index 0000000..aa8e03d --- /dev/null +++ b/phone_app/.metadata @@ -0,0 +1,30 @@ +# 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: "ad70ec4617166f1c38e5d2bfd388af71fda14f06" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + - platform: android + create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + + # 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' diff --git a/phone_app/README.md b/phone_app/README.md new file mode 100644 index 0000000..ba3a549 --- /dev/null +++ b/phone_app/README.md @@ -0,0 +1,17 @@ +# wake_alarm_sync + +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. diff --git a/phone_app/analysis_options.yaml b/phone_app/analysis_options.yaml new file mode 100644 index 0000000..a6117f6 --- /dev/null +++ b/phone_app/analysis_options.yaml @@ -0,0 +1,24 @@ +include: package:very_good_analysis/analysis_options.yaml + +analyzer: + language: + strict-casts: true + strict-inference: true + strict-raw-types: true + errors: + missing_required_param: error + unnecessary_null_comparison: error + dead_code: error + invalid_annotation_target: error + +linter: + rules: + always_use_package_imports: true + avoid_print: true + avoid_relative_lib_imports: true + cancel_subscriptions: true + close_sinks: true + directives_ordering: true + lines_longer_than_80_chars: true + public_member_api_docs: true + unawaited_futures: true diff --git a/phone_app/android/.gitignore b/phone_app/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/phone_app/android/.gitignore @@ -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 diff --git a/phone_app/android/app/build.gradle.kts b/phone_app/android/app/build.gradle.kts new file mode 100644 index 0000000..2b0e342 --- /dev/null +++ b/phone_app/android/app/build.gradle.kts @@ -0,0 +1,44 @@ +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 = "com.kuhy.wake_alarm_sync" + 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 = "com.kuhy.wake_alarm_sync" + // flutter_secure_storage requires API 23+ (Android Keystore). + 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 = "../.." +} diff --git a/phone_app/android/app/src/debug/AndroidManifest.xml b/phone_app/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/phone_app/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/phone_app/android/app/src/main/AndroidManifest.xml b/phone_app/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..706b385 --- /dev/null +++ b/phone_app/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/MainActivity.kt b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/MainActivity.kt new file mode 100644 index 0000000..87dfca8 --- /dev/null +++ b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/MainActivity.kt @@ -0,0 +1,5 @@ +package com.kuhy.wake_alarm_sync + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/phone_app/android/app/src/main/res/drawable-v21/launch_background.xml b/phone_app/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/phone_app/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/phone_app/android/app/src/main/res/drawable/launch_background.xml b/phone_app/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/phone_app/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/phone_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/phone_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..db77bb4b7b0906d62b1847e87f15cdcacf6a4f29 GIT binary patch literal 544 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY3?!3`olAj~WQl7;NpOBzNqJ&XDuZK6ep0G} zXKrG8YEWuoN@d~6R2!h8bpbvhu0Wd6uZuB!w&u2PAxD2eNXD>P5D~Wn-+_Wa#27Xc zC?Zj|6r#X(-D3u$NCt}(Ms06KgJ4FxJVv{GM)!I~&n8Bnc94O7-Hd)cjDZswgC;Qs zO=b+9!WcT8F?0rF7!Uys2bs@gozCP?z~o%U|N3vA*22NaGQG zlg@K`O_XuxvZ&Ks^m&R!`&1=spLvfx7oGDKDwpwW`#iqdw@AL`7MR}m`rwr|mZgU`8P7SBkL78fFf!WnuYWm$5Z0 zNXhDbCv&49sM544K|?c)WrFfiZvCi9h0O)B3Pgg&ebxsLQ05GG~ AQ2+n{ literal 0 HcmV?d00001 diff --git a/phone_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/phone_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..17987b79bb8a35cc66c3c1fd44f5a5526c1b78be GIT binary patch literal 442 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA3?vioaBc-sk|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*D5Xx&nMcT!A!W`0S9QKQy;}1Cl^CgaH=;G9cpY;r$Q>i*pfB zP2drbID<_#qf;rPZx^FqH)F_D#*k@@q03KywUtLX8Ua?`H+NMzkczFPK3lFz@i_kW%1NOn0|D2I9n9wzH8m|-tHjsw|9>@K=iMBhxvkv6m8Y-l zytQ?X=U+MF$@3 zt`~i=@j|6y)RWMK--}M|=T`o&^Ni>IoWKHEbBXz7?A@mgWoL>!*SXo`SZH-*HSdS+ yn*9;$7;m`l>wYBC5bq;=U}IMqLzqbYCidGC!)_gkIk_C@Uy!y&wkt5C($~2D>~)O*cj@FGjOCM)M>_ixfudOh)?xMu#Fs z#}Y=@YDTwOM)x{K_j*Q;dPdJ?Mz0n|pLRx{4n|)f>SXlmV)XB04CrSJn#dS5nK2lM zrZ9#~WelCp7&e13Y$jvaEXHskn$2V!!DN-nWS__6T*l;H&Fopn?A6HZ-6WRLFP=R` zqG+CE#d4|IbyAI+rJJ`&x9*T`+a=p|0O(+s{UBcyZdkhj=yS1>AirP+0R;mf2uMgM zC}@~JfByORAh4SyRgi&!(cja>F(l*O+nd+@4m$|6K6KDn_&uvCpV23&>G9HJp{xgg zoq1^2_p9@|WEo z*X_Uko@K)qYYv~>43eQGMdbiGbo>E~Q& zrYBH{QP^@Sti!`2)uG{irBBq@y*$B zi#&(U-*=fp74j)RyIw49+0MRPMRU)+a2r*PJ$L5roHt2$UjExCTZSbq%V!HeS7J$N zdG@vOZB4v_lF7Plrx+hxo7(fCV&}fHq)$ literal 0 HcmV?d00001 diff --git a/phone_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/phone_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..d5f1c8d34e7a88e3f88bea192c3a370d44689c3c GIT binary patch literal 1031 zcmeAS@N?(olHy`uVBq!ia0vp^6F``Q8Ax83A=Cw=BuiW)N`mv#O3D+9QW+dm@{>{( zJaZG%Q-e|yQz{EjrrIztFa`(sgt!6~Yi|1%a`XoT0ojZ}lNrNjb9xjc(B0U1_% zz5^97Xt*%oq$rQy4?0GKNfJ44uvxI)gC`h-NZ|&0-7(qS@?b!5r36oQ}zyZrNO3 zMO=Or+<~>+A&uN&E!^Sl+>xE!QC-|oJv`ApDhqC^EWD|@=#J`=d#Xzxs4ah}w&Jnc z$|q_opQ^2TrnVZ0o~wh<3t%W&flvYGe#$xqda2bR_R zvPYgMcHgjZ5nSA^lJr%;<&0do;O^tDDh~=pIxA#coaCY>&N%M2^tq^U%3DB@ynvKo}b?yu-bFc-u0JHzced$sg7S3zqI(2 z#Km{dPr7I=pQ5>FuK#)QwK?Y`E`B?nP+}U)I#c1+FM*1kNvWG|a(TpksZQ3B@sD~b zpQ2)*V*TdwjFOtHvV|;OsiDqHi=6%)o4b!)x$)%9pGTsE z-JL={-Ffv+T87W(Xpooq<`r*VzWQcgBN$$`u}f>-ZQI1BB8ykN*=e4rIsJx9>z}*o zo~|9I;xof literal 0 HcmV?d00001 diff --git a/phone_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/phone_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..4d6372eebdb28e45604e46eeda8dd24651419bc0 GIT binary patch literal 1443 zcmb`G{WsKk6vsdJTdFg%tJav9_E4vzrOaqkWF|A724Nly!y+?N9`YV6wZ}5(X(D_N(?!*n3`|_r0Hc?=PQw&*vnU?QTFY zB_MsH|!j$PP;I}?dppoE_gA(4uc!jV&0!l7_;&p2^pxNo>PEcNJv za5_RT$o2Mf!<+r?&EbHH6nMoTsDOa;mN(wv8RNsHpG)`^ymG-S5By8=l9iVXzN_eG%Xg2@Xeq76tTZ*dGh~Lo9vl;Zfs+W#BydUw zCkZ$o1LqWQO$FC9aKlLl*7x9^0q%0}$OMlp@Kk_jHXOjofdePND+j!A{q!8~Jn+s3 z?~~w@4?egS02}8NuulUA=L~QQfm;MzCGd)XhiftT;+zFO&JVyp2mBww?;QByS_1w! zrQlx%{^cMj0|Bo1FjwY@Q8?Hx0cIPF*@-ZRFpPc#bBw{5@tD(5%sClzIfl8WU~V#u zm5Q;_F!wa$BSpqhN>W@2De?TKWR*!ujY;Yylk_X5#~V!L*Gw~;$%4Q8~Mad z@`-kG?yb$a9cHIApZDVZ^U6Xkp<*4rU82O7%}0jjHlK{id@?-wpN*fCHXyXh(bLt* zPc}H-x0e4E&nQ>y%B-(EL=9}RyC%MyX=upHuFhAk&MLbsF0LP-q`XnH78@fT+pKPW zu72MW`|?8ht^tz$iC}ZwLp4tB;Q49K!QCF3@!iB1qOI=?w z7In!}F~ij(18UYUjnbmC!qKhPo%24?8U1x{7o(+?^Zu0Hx81|FuS?bJ0jgBhEMzf< zCgUq7r2OCB(`XkKcN-TL>u5y#dD6D!)5W?`O5)V^>jb)P)GBdy%t$uUMpf$SNV31$ zb||OojAbvMP?T@$h_ZiFLFVHDmbyMhJF|-_)HX3%m=CDI+ID$0^C>kzxprBW)hw(v zr!Gmda);ICoQyhV_oP5+C%?jcG8v+D@9f?Dk*!BxY}dazmrT@64UrP3hlslANK)bq z$67n83eh}OeW&SV@HG95P|bjfqJ7gw$e+`Hxo!4cx`jdK1bJ>YDSpGKLPZ^1cv$ek zIB?0S<#tX?SJCLWdMd{-ME?$hc7A$zBOdIJ)4!KcAwb=VMov)nK;9z>x~rfT1>dS+ zZ6#`2v@`jgbqq)P22H)Tx2CpmM^o1$B+xT6`(v%5xJ(?j#>Q$+rx_R|7TzDZe{J6q zG1*EcU%tE?!kO%^M;3aM6JN*LAKUVb^xz8-Pxo#jR5(-KBeLJvA@-gxNHx0M-ZJLl z;#JwQoh~9V?`UVo#}{6ka@II>++D@%KqGpMdlQ}?9E*wFcf5(#XQnP$Dk5~%iX^>f z%$y;?M0BLp{O3a(-4A?ewryHrrD%cx#Q^%KY1H zNre$ve+vceSLZcNY4U(RBX&)oZn*Py()h)XkE?PL$!bNb{N5FVI2Y%LKEm%yvpyTP z(1P?z~7YxD~Rf<(a@_y` literal 0 HcmV?d00001 diff --git a/phone_app/android/app/src/main/res/values-night/styles.xml b/phone_app/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/phone_app/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/phone_app/android/app/src/main/res/values/styles.xml b/phone_app/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/phone_app/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/phone_app/android/app/src/profile/AndroidManifest.xml b/phone_app/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/phone_app/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/phone_app/android/build.gradle.kts b/phone_app/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/phone_app/android/build.gradle.kts @@ -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("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/phone_app/android/gradle.properties b/phone_app/android/gradle.properties new file mode 100644 index 0000000..e96108c --- /dev/null +++ b/phone_app/android/gradle.properties @@ -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 diff --git a/phone_app/android/gradle/wrapper/gradle-wrapper.properties b/phone_app/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..2d428bf --- /dev/null +++ b/phone_app/android/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/phone_app/android/settings.gradle.kts b/phone_app/android/settings.gradle.kts new file mode 100644 index 0000000..c21f0c5 --- /dev/null +++ b/phone_app/android/settings.gradle.kts @@ -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") diff --git a/phone_app/lib/main.dart b/phone_app/lib/main.dart new file mode 100644 index 0000000..c56c898 --- /dev/null +++ b/phone_app/lib/main.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; + +import 'package:wake_alarm_sync/screens/home_screen.dart'; + +/// Entry point. +void main() => runApp(const WakeAlarmSyncApp()); + +/// Root application widget. +class WakeAlarmSyncApp extends StatelessWidget { + /// Creates a [WakeAlarmSyncApp]. + const WakeAlarmSyncApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Wake Alarm Sync', + theme: ThemeData.dark(useMaterial3: true).copyWith( + colorScheme: ColorScheme.fromSeed( + seedColor: Colors.orange, + brightness: Brightness.dark, + ), + ), + home: const HomeScreen(), + ); + } +} diff --git a/phone_app/lib/screens/home_screen.dart b/phone_app/lib/screens/home_screen.dart new file mode 100644 index 0000000..02a864d --- /dev/null +++ b/phone_app/lib/screens/home_screen.dart @@ -0,0 +1,215 @@ +import 'dart:async'; + +import 'package:android_intent_plus/android_intent.dart'; +import 'package:flutter/material.dart'; + +import 'package:wake_alarm_sync/screens/settings_screen.dart'; +import 'package:wake_alarm_sync/services/gist_service.dart'; +import 'package:wake_alarm_sync/services/sync_settings.dart'; + +/// Main screen: displays the current alarm time and syncs it to the Gist. +class HomeScreen extends StatefulWidget { + /// Creates a [HomeScreen]. + const HomeScreen({super.key}); + + @override + State createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State { + int _hour = 7; + int _minute = 0; + bool _busy = false; + String _status = ''; + bool _configured = false; + + @override + void initState() { + super.initState(); + unawaited(_loadSavedTime()); + } + + Future _loadSavedTime() async { + final settings = await SyncSettings.load(); + if (!mounted) return; + setState(() => _configured = settings.isConfigured); + if (!settings.isConfigured) return; + + try { + final time = await readGistAlarm( + token: settings.token, + gistId: settings.gistId, + ); + if (time != null && mounted) { + setState(() { + _hour = time.hour; + _minute = time.minute; + _status = 'Loaded from Gist'; + }); + } + } on Exception catch (_) { + // Non-fatal: local defaults used. + } + } + + Future _pickTime() async { + final picked = await showTimePicker( + context: context, + initialTime: TimeOfDay(hour: _hour, minute: _minute), + ); + if (picked != null) { + setState(() { + _hour = picked.hour; + _minute = picked.minute; + _status = ''; + }); + } + } + + /// Opens the system Clock app pre-filled via ACTION_SET_ALARM. + /// + /// android_intent_plus v5 uses startActivityForResult; the Clock app + /// never sends a result, so we time out after 3 s rather than hang forever. + Future _setPhoneAlarm() async { + final intent = AndroidIntent( + action: 'android.intent.action.SET_ALARM', + arguments: { + 'android.intent.extra.alarm.HOUR': _hour, + 'android.intent.extra.alarm.MINUTES': _minute, + 'android.intent.extra.alarm.SKIP_UI': false, + 'android.intent.extra.alarm.MESSAGE': 'Wake Alarm', + }, + ); + await intent.launch().timeout( + const Duration(seconds: 3), + onTimeout: () {}, + ); + } + + Future _syncAndSet() async { + final settings = await SyncSettings.load(); + if (!settings.isConfigured) { + setState( + () => _status = 'Configure GitHub in Settings first.', + ); + return; + } + + setState(() { + _busy = true; + _status = 'Syncing to Gist…'; + }); + try { + await writeGistAlarm( + token: settings.token, + gistId: settings.gistId, + hour: _hour, + minute: _minute, + ); + setState( + () => _status = 'Gist updated ✓ — opening Clock app…', + ); + await _setPhoneAlarm(); + setState( + () => _status = 'Done! Confirm the alarm in the Clock app.', + ); + } on Exception catch (e) { + setState(() => _status = 'Error: $e'); + } finally { + setState(() => _busy = false); + } + } + + String _fmt(int h, int m) => + '${h.toString().padLeft(2, '0')}:${m.toString().padLeft(2, '0')}'; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Wake Alarm Sync'), + actions: [ + IconButton( + icon: const Icon(Icons.settings), + onPressed: () async { + await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const SettingsScreen(), + ), + ); + await _loadSavedTime(); + }, + ), + ], + ), + body: Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('Alarm time', style: TextStyle(fontSize: 16)), + const SizedBox(height: 16), + GestureDetector( + onTap: _pickTime, + child: Text( + _fmt(_hour, _minute), + style: Theme.of(context) + .textTheme + .displayLarge + ?.copyWith(fontWeight: FontWeight.bold), + ), + ), + const SizedBox(height: 8), + TextButton( + onPressed: _pickTime, + child: const Text('Tap to change'), + ), + const SizedBox(height: 32), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: _busy ? null : _syncAndSet, + icon: _busy + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const Icon(Icons.alarm_add), + label: const Text('Set Alarm & Sync to PC'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + textStyle: const TextStyle(fontSize: 18), + ), + ), + ), + const SizedBox(height: 24), + if (_status.isNotEmpty) + Text( + _status, + textAlign: TextAlign.center, + style: TextStyle( + color: _status.contains('Error') + ? Colors.redAccent + : Colors.greenAccent, + ), + ), + if (!_configured) ...[ + const SizedBox(height: 16), + const Text( + 'Open Settings (gear icon) to connect GitHub.', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.orange), + ), + ], + ], + ), + ), + ), + ); + } +} diff --git a/phone_app/lib/screens/settings_screen.dart b/phone_app/lib/screens/settings_screen.dart new file mode 100644 index 0000000..876bdad --- /dev/null +++ b/phone_app/lib/screens/settings_screen.dart @@ -0,0 +1,262 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import 'package:wake_alarm_sync/services/gist_service.dart'; +import 'package:wake_alarm_sync/services/github_device_auth.dart'; +import 'package:wake_alarm_sync/services/sync_settings.dart'; + +/// Settings screen for GitHub connection and Gist configuration. +/// +/// The "Connect GitHub" button runs the OAuth device flow — the user +/// enters a short code on github.com; no token pasting required. +class SettingsScreen extends StatefulWidget { + /// Creates a [SettingsScreen]. + const SettingsScreen({super.key}); + + @override + State createState() => _SettingsScreenState(); +} + +class _SettingsScreenState extends State { + SyncSettings _settings = const SyncSettings(token: '', gistId: ''); + bool _busy = false; + String _status = ''; + + @override + void initState() { + super.initState(); + unawaited(_load()); + } + + Future _load() async { + final s = await SyncSettings.load(); + if (mounted) setState(() => _settings = s); + } + + Future _connectGitHub() async { + final auth = GitHubDeviceAuth( + clientId: SyncSettings.defaultClientId, + ); + try { + final device = await auth.requestDeviceCode(); + if (!mounted) return; + final token = await showDialog( + context: context, + barrierDismissible: false, + builder: (_) => _DeviceCodeDialog(device: device, auth: auth), + ); + if (token == null || token.isEmpty) return; + + setState(() { + _busy = true; + _status = 'Connected — creating Gist…'; + }); + final gistId = await createGist(token: token); + final updated = SyncSettings(token: token, gistId: gistId); + await updated.save(); + if (mounted) { + setState(() { + _settings = updated; + _busy = false; + _status = 'Connected! Gist ID: $gistId'; + }); + } + } on Exception catch (e) { + if (mounted) { + setState(() { + _busy = false; + _status = 'Error: $e'; + }); + } + } finally { + auth.close(); + } + } + + Future _disconnect() async { + await SyncSettings.clear(); + if (mounted) { + setState(() { + _settings = const SyncSettings(token: '', gistId: ''); + _status = 'Disconnected.'; + }); + } + } + + @override + Widget build(BuildContext context) { + final isConnected = _settings.isConfigured; + return Scaffold( + appBar: AppBar(title: const Text('Settings')), + body: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _StatusCard(connected: isConnected, gistId: _settings.gistId), + const SizedBox(height: 16), + if (!isConnected) + ElevatedButton.icon( + onPressed: _busy ? null : _connectGitHub, + icon: _busy + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.link), + label: const Text('Connect GitHub'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + textStyle: const TextStyle(fontSize: 16), + ), + ) + else + OutlinedButton.icon( + onPressed: _busy ? null : _disconnect, + icon: const Icon(Icons.link_off), + label: const Text('Disconnect'), + ), + const SizedBox(height: 16), + if (_status.isNotEmpty) + Text( + _status, + style: TextStyle( + color: _status.contains('Error') + ? Colors.redAccent + : Colors.greenAccent, + ), + ), + const Divider(height: 32), + const Text( + 'PC config — create this file:\n' + '~/.config/wake_alarm/gist_sync.json\n\n' + '{ "token": "", "gist_id": "" }', + style: TextStyle(fontSize: 12, color: Colors.grey), + ), + ], + ), + ), + ); + } +} + +class _StatusCard extends StatelessWidget { + const _StatusCard({required this.connected, required this.gistId}); + + final bool connected; + final String gistId; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Icon( + connected ? Icons.check_circle : Icons.cancel, + color: connected ? Colors.greenAccent : Colors.redAccent, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + connected ? 'Connected — Gist: $gistId' : 'Not connected', + ), + ), + ], + ), + ), + ); + } +} + +/// Dialog shown while waiting for the user to authorize on GitHub. +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(); + unawaited(_poll()); + } + + Future _poll() async { + try { + final token = await widget.auth.pollForToken(widget.device); + if (mounted) Navigator.of(context).pop(token); + } on Exception catch (e) { + if (mounted) setState(() => _error = '$e'); + } + } + + Future _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'), + ), + ], + ); + } +} diff --git a/phone_app/lib/services/gist_service.dart b/phone_app/lib/services/gist_service.dart new file mode 100644 index 0000000..92f8eaf --- /dev/null +++ b/phone_app/lib/services/gist_service.dart @@ -0,0 +1,87 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +const _gistFileName = 'wake_alarm_sync.json'; + +/// Reads the current alarm time from the Gist. +/// +/// Returns null if the Gist is not yet configured or the request fails. +Future<({int hour, int minute})?> readGistAlarm({ + required String token, + required String gistId, +}) async { + final uri = Uri.parse('https://api.github.com/gists/$gistId'); + final response = await http.get( + uri, + headers: {'Authorization': 'Bearer $token'}, + ); + if (response.statusCode != 200) return null; + + final body = jsonDecode(response.body) as Map; + final files = body['files'] as Map?; + final fileEntry = files?[_gistFileName] as Map?; + final fileContent = fileEntry?['content'] as String?; + if (fileContent == null) return null; + + final data = jsonDecode(fileContent) as Map; + final hour = data['alarm_hour'] as int?; + final minute = data['alarm_minute'] as int?; + if (hour == null || minute == null) return null; + return (hour: hour, minute: minute); +} + +/// Writes the chosen alarm time to the Gist, creating the file if absent. +Future writeGistAlarm({ + required String token, + required String gistId, + required int hour, + required int minute, +}) async { + final content = jsonEncode({'alarm_hour': hour, 'alarm_minute': minute}); + final uri = Uri.parse('https://api.github.com/gists/$gistId'); + final response = await http.patch( + uri, + headers: { + 'Authorization': 'Bearer $token', + 'Content-Type': 'application/json', + }, + body: jsonEncode({ + 'files': { + _gistFileName: {'content': content}, + }, + }), + ); + if (response.statusCode != 200) { + throw Exception( + 'Gist write failed: HTTP ${response.statusCode}', + ); + } +} + +/// Creates a new private Gist and returns its ID. +Future createGist({required String token}) async { + final content = jsonEncode({'alarm_hour': 7, 'alarm_minute': 0}); + final uri = Uri.parse('https://api.github.com/gists'); + final response = await http.post( + uri, + headers: { + 'Authorization': 'Bearer $token', + 'Content-Type': 'application/json', + }, + body: jsonEncode({ + 'description': 'wake_alarm PC sync', + 'public': false, + 'files': { + _gistFileName: {'content': content}, + }, + }), + ); + if (response.statusCode != 201) { + throw Exception( + 'Gist create failed: HTTP ${response.statusCode}', + ); + } + final body = jsonDecode(response.body) as Map; + return body['id'] as String; +} diff --git a/phone_app/lib/services/github_device_auth.dart b/phone_app/lib/services/github_device_auth.dart new file mode 100644 index 0000000..31b405c --- /dev/null +++ b/phone_app/lib/services/github_device_auth.dart @@ -0,0 +1,165 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:http/http.dart' as http; + +/// First-stage response of the GitHub OAuth Device Flow. +class DeviceCodeResponse { + /// Creates a [DeviceCodeResponse]. + const DeviceCodeResponse({ + required this.deviceCode, + required this.userCode, + required this.verificationUri, + required this.interval, + required this.expiresIn, + }); + + /// Creates a [DeviceCodeResponse] from a GitHub API JSON response. + factory DeviceCodeResponse.fromJson(Map 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, + ); + } + + /// 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]. + final String verificationUri; + + /// Minimum seconds to wait between polls. + final int interval; + + /// Seconds until [deviceCode] expires. + final int expiresIn; +} + +/// Raised when the device-flow authorization fails or is declined. +class DeviceAuthException implements Exception { + /// Creates a [DeviceAuthException]. + DeviceAuthException(this.code, this.message); + + /// GitHub error code, e.g. `access_denied`, `expired_token`. + final String code; + + /// Human-readable description of the error. + 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 the Gist service. +class GitHubDeviceAuth { + /// Creates a [GitHubDeviceAuth] instance. + GitHubDeviceAuth({ + required this.clientId, + this.scope = 'gist', + http.Client? httpClient, + Future Function(Duration)? delay, + }) : _http = httpClient ?? http.Client(), + _delay = delay ?? Future.delayed; + + /// OAuth App client ID (public identifier, not a secret). + final String clientId; + + /// OAuth scope requested. `gist` is required to read/write Gists. + final String scope; + + final http.Client _http; + final Future 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 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, + ); + } + + /// 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 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)); + http.Response res; + try { + res = await _http.post( + Uri.parse(_tokenUrl), + headers: const {'Accept': 'application/json'}, + body: { + 'client_id': clientId, + 'device_code': device.deviceCode, + 'grant_type': _grantType, + }, + ); + } on http.ClientException catch (_) { + // GitHub can close the connection after the user authorizes; retry. + continue; + } on SocketException catch (_) { + continue; + } + final json = jsonDecode(res.body) as Map; + + final token = json['access_token'] as String?; + if (token != null) return token; + + switch (json['error'] as String?) { + case 'authorization_pending': + continue; + case 'slow_down': + 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.'); + } + + /// Closes the underlying HTTP client. + void close() => _http.close(); +} diff --git a/phone_app/lib/services/sync_settings.dart b/phone_app/lib/services/sync_settings.dart new file mode 100644 index 0000000..5e29293 --- /dev/null +++ b/phone_app/lib/services/sync_settings.dart @@ -0,0 +1,104 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Locally-stored GitHub sync configuration for wake_alarm_sync. +/// +/// The GitHub token is kept in the OS keystore (Android Keystore) via +/// [FlutterSecureStorage]; the Gist ID (not a secret) lives in +/// `SharedPreferences`. Legacy builds stored the token in plaintext +/// prefs; [load]/[save] migrate it transparently and never drop the +/// plaintext copy until a secure write is confirmed. +class SyncSettings { + /// Creates a [SyncSettings]. + const SyncSettings({required this.token, required this.gistId}); + + /// GitHub access token (PAT or device-flow token). + final String token; + + /// ID of the private Gist used to sync the alarm time. + final String gistId; + + /// Baked-in OAuth App client ID for the device-flow "Connect" button. + /// Device-flow client IDs are public identifiers, not secrets. + static const defaultClientId = 'Ov23liGUPrPe1FFRSel6'; + + /// True when both token and gist ID are present and sync can proceed. + bool get isConfigured => token.isNotEmpty && gistId.isNotEmpty; + + // Legacy plaintext key — read-only; removed once migrated to keystore. + static const _kLegacyToken = 'github_token'; + static const _kGistId = 'gist_id'; + static const _secureTokenKey = 'github_token'; + + static const _secure = FlutterSecureStorage(); + + /// Loads settings from persistent storage. + static Future load() async { + final prefs = await SharedPreferences.getInstance(); + return SyncSettings( + token: await _loadToken(prefs), + gistId: prefs.getString(_kGistId) ?? '', + ); + } + + /// Reads the token from the keystore, falling back to the legacy + /// plaintext value. A legacy value is migrated into the keystore on + /// read, but only deleted from prefs once that write succeeds. + static Future _loadToken(SharedPreferences prefs) async { + String? secure; + try { + secure = await _secure.read(key: _secureTokenKey); + } on Exception catch (_) { + secure = null; + } + if (secure != null && secure.isNotEmpty) return secure; + + final legacy = prefs.getString(_kLegacyToken) ?? ''; + if (legacy.isNotEmpty && await _writeSecureToken(legacy)) { + await prefs.remove(_kLegacyToken); + } + return legacy; + } + + /// Persists current settings. + Future save() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_kGistId, gistId); + if (await _writeSecureToken(token)) { + await prefs.remove(_kLegacyToken); + } else { + await prefs.setString(_kLegacyToken, token); + } + } + + /// Removes all stored credentials. + static Future clear() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_kGistId); + await prefs.remove(_kLegacyToken); + await _writeSecureToken(''); + } + + /// Writes [token] to the keystore. Returns false if the platform + /// secret service is unavailable. + static Future _writeSecureToken(String token) async { + try { + if (token.isEmpty) { + await _secure.delete(key: _secureTokenKey); + } else { + await _secure.write(key: _secureTokenKey, value: token); + } + return true; + } on Exception catch (_) { + return false; + } + } + + /// Returns a copy with optional field overrides. + SyncSettings copyWith({String? token, String? gistId}) { + return SyncSettings( + token: token ?? this.token, + gistId: gistId ?? this.gistId, + ); + } +} diff --git a/phone_app/pubspec.lock b/phone_app/pubspec.lock new file mode 100644 index 0000000..3bd6801 --- /dev/null +++ b/phone_app/pubspec.lock @@ -0,0 +1,618 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + android_intent_plus: + dependency: "direct main" + description: + name: android_intent_plus + sha256: "2329378af63f49b985cb2e110ac784d08374f1e2b1984be77ba9325b1c8cce11" + url: "https://pub.dev" + source: hosted + version: "5.3.1" + 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" + 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" + 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" + 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" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" + url: "https://pub.dev" + source: hosted + version: "9.2.4" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" + url: "https://pub.dev" + source: hosted + version: "3.1.3" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + 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" + 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" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" + 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" + 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" + 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: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.dev" + source: hosted + version: "2.1.6" + 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: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + 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" + 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" + 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" + 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" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + very_good_analysis: + dependency: "direct dev" + description: + name: very_good_analysis + sha256: "481af67ab5877af20325251dc215a4ebac7666a1c8cf09198ffd457bc612b33d" + url: "https://pub.dev" + source: hosted + version: "10.3.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" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + 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" diff --git a/phone_app/pubspec.yaml b/phone_app/pubspec.yaml new file mode 100644 index 0000000..c8b0e63 --- /dev/null +++ b/phone_app/pubspec.yaml @@ -0,0 +1,26 @@ +name: wake_alarm_sync +description: "Wake alarm phone-side sync app — sets phone alarm and writes time to GitHub Gist." +publish_to: 'none' + +version: 1.0.0+1 + +environment: + sdk: ^3.12.2 + +dependencies: + android_intent_plus: ^5.0.0 + cupertino_icons: ^1.0.8 + flutter: + sdk: flutter + flutter_secure_storage: ^9.0.0 + http: ^1.2.0 + shared_preferences: ^2.3.0 + url_launcher: ^6.3.0 + +dev_dependencies: + flutter_test: + sdk: flutter + very_good_analysis: ^10.2.0 + +flutter: + uses-material-design: true diff --git a/phone_app/test/widget_test.dart b/phone_app/test/widget_test.dart new file mode 100644 index 0000000..c9bec64 --- /dev/null +++ b/phone_app/test/widget_test.dart @@ -0,0 +1,9 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:wake_alarm_sync/main.dart'; + +void main() { + testWidgets('App renders without crashing', (tester) async { + await tester.pumpWidget(const WakeAlarmSyncApp()); + expect(find.text('Wake Alarm Sync'), findsOneWidget); + }); +}