Add wake_alarm_sync phone-side Flutter companion app
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RRCdR4D8SaVrN2xraMQxM
This commit is contained in:
Krzysztof Rudnicki 2026-07-10 23:16:11 +02:00
parent 32d0994ba6
commit 0704e44912
32 changed files with 1865 additions and 0 deletions

45
phone_app/.gitignore vendored Normal file
View 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

30
phone_app/.metadata Normal file
View File

@ -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'

17
phone_app/README.md Normal file
View File

@ -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.

View File

@ -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

14
phone_app/android/.gitignore vendored Normal file
View 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

View File

@ -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 = "../.."
}

View 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>

View File

@ -0,0 +1,39 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="com.android.alarm.permission.SET_ALARM"/>
<application
android:label="wake_alarm_sync"
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">
<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>
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
<intent>
<action android:name="android.intent.action.SET_ALARM"/>
</intent>
</queries>
</manifest>

View File

@ -0,0 +1,5 @@
package com.kuhy.wake_alarm_sync
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()

View 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>

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View 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>

View 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>

View 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>

View 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)
}

View 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

View 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

View 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")

26
phone_app/lib/main.dart Normal file
View File

@ -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(),
);
}
}

View File

@ -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<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
int _hour = 7;
int _minute = 0;
bool _busy = false;
String _status = '';
bool _configured = false;
@override
void initState() {
super.initState();
unawaited(_loadSavedTime());
}
Future<void> _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<void> _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<void> _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<void> _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<void>(
context,
MaterialPageRoute<void>(
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),
),
],
],
),
),
),
);
}
}

View File

@ -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<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
SyncSettings _settings = const SyncSettings(token: '', gistId: '');
bool _busy = false;
String _status = '';
@override
void initState() {
super.initState();
unawaited(_load());
}
Future<void> _load() async {
final s = await SyncSettings.load();
if (mounted) setState(() => _settings = s);
}
Future<void> _connectGitHub() async {
final auth = GitHubDeviceAuth(
clientId: SyncSettings.defaultClientId,
);
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.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<void> _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": "<same token>", "gist_id": "<shown above>" }',
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<void> _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<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'),
),
],
);
}
}

View File

@ -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<String, dynamic>;
final files = body['files'] as Map<String, dynamic>?;
final fileEntry = files?[_gistFileName] as Map<String, dynamic>?;
final fileContent = fileEntry?['content'] as String?;
if (fileContent == null) return null;
final data = jsonDecode(fileContent) as Map<String, dynamic>;
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<void> 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<String> 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<String, dynamic>;
return body['id'] as String;
}

View File

@ -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<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,
);
}
/// 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<void> Function(Duration)? delay,
}) : _http = httpClient ?? http.Client(),
_delay = delay ?? Future<void>.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<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));
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<String, dynamic>;
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();
}

View File

@ -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<SyncSettings> 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<String> _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<void> 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<void> 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<bool> _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,
);
}
}

618
phone_app/pubspec.lock Normal file
View File

@ -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"

26
phone_app/pubspec.yaml Normal file
View File

@ -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

View File

@ -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);
});
}