commit 67ecb15c0367e5fc0ac6c71aba3394c33b59295d Author: leavy Date: Thu Jun 11 00:10:50 2026 +0800 release: seal Focus Seed 1.0.0 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dad790f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,18 @@ +* text=auto +*.dart text eol=lf +*.yaml text eol=lf +*.yml text eol=lf +*.md text eol=lf +*.json text eol=lf +*.kt text eol=lf +*.kts text eol=lf +*.xml text eol=lf +*.gradle text eol=lf +*.properties text eol=lf +*.png binary +*.jpg binary +*.jpeg binary +*.webp binary +*.jks binary +*.aab binary +*.apk binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2a55c9a --- /dev/null +++ b/.gitignore @@ -0,0 +1,54 @@ +# 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 +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +coverage/ +*.aab +*.apk + +# Tester email lists can contain personal data. +/launch/play-console/tester-email-list*.csv +/launch/play-console/testers.csv +/launch/screenshots/device-smoke-*.png +/launch/screenshots/device-05-settings.png + +# 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/.metadata b/.metadata new file mode 100644 index 0000000..82b7d8f --- /dev/null +++ b/.metadata @@ -0,0 +1,33 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "c23637390482d4cf9598c3ce3f2be31aa7332daf" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf + base_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf + - platform: android + create_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf + base_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf + - platform: ios + create_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf + base_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf + + # 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/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5bdc9ca --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,31 @@ +# Changelog + +All notable product changes are recorded here. + +## [1.0.0] - 2026-06-10 + +### Added + +- Daily anchor task. +- 15, 25, and 45 minute focus timer. +- Habit creation, rename, deletion, and daily completion. +- One-minute reflection with mood selection. +- Seven-day progress pulse. +- Local JSON export and import. +- Local data deletion and in-app privacy explanation. +- Light and dark Material 3 themes. +- No-op analytics and monetization boundaries for future integrations. + +### Verified + +- Strict Dart analysis passed. +- Widget tests passed. +- Android App Bundle release build passed. +- Release smoke test passed on MT2110 with Android 14. +- Android manifest contains no sensitive user-data permissions. + +### Release posture + +- English-only V1. +- No account, server, ads, billing, analytics, or cloud sync. +- Released first through Google Play testing tracks. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b58034d --- /dev/null +++ b/README.md @@ -0,0 +1,46 @@ +# Focus Seed + +Focus Seed is a private, offline-first focus timer, habit tracker, and daily reflection app built with Flutter. + +## V1 Product Loop + +1. Choose one anchor task for today. +2. Complete a focused session. +3. Check off small habits. +4. Save a one-minute reflection. +5. Review the seven-day pulse. + +## Architecture + +The app uses a modular feature structure under `lib/src`: + +- `app`: application shell and theme +- `core`: date, JSON, and collection utilities +- `data`: local persistence boundary +- `models`: serializable domain models +- `services`: analytics and monetization interfaces +- `features`: user-facing product flows +- `widgets`: reusable UI components + +See `launch/architecture.md` for the complete responsibility map. + +## Privacy + +Version 1.0.0 has no account system, server, advertising SDK, analytics SDK, billing SDK, or sensitive Android permissions. User data remains on the device unless the user explicitly exports it. + +## Development + +```text +flutter pub get +flutter analyze +flutter test +flutter run +``` + +Release build: + +```text +flutter build appbundle --release +``` + +The upload keystore and `key.properties` are intentionally excluded from Git. Keep them backed up securely because future Play updates require the same upload key. diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..425bff0 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,44 @@ +include: package:flutter_lints/flutter.yaml + +analyzer: + errors: + dead_code: error + invalid_use_of_protected_member: error + invalid_use_of_visible_for_testing_member: error + unused_import: error + unused_local_variable: error + language: + strict-casts: true + strict-inference: true + strict-raw-types: true + +linter: + rules: + always_declare_return_types: true + always_put_required_named_parameters_first: true + avoid_bool_literals_in_conditional_expressions: true + avoid_catches_without_on_clauses: true + avoid_empty_else: true + avoid_print: true + avoid_redundant_argument_values: true + avoid_slow_async_io: true + avoid_type_to_string: true + cancel_subscriptions: true + close_sinks: true + directives_ordering: true + no_adjacent_strings_in_list: true + no_duplicate_case_values: true + omit_local_variable_types: true + prefer_asserts_with_message: true + prefer_const_constructors: true + prefer_const_declarations: true + prefer_final_fields: true + prefer_final_in_for_each: true + prefer_final_locals: true + prefer_single_quotes: true + require_trailing_commas: true + sort_child_properties_last: true + unawaited_futures: true + unnecessary_await_in_return: true + unnecessary_lambdas: true + use_build_context_synchronously: true diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..be82512 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,11 @@ +/.gradle +/captures/ +/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/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..60ceb16 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,56 @@ +import java.util.Properties + +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +val keystoreProperties = Properties() +val keystorePropertiesFile = rootProject.file("key.properties") +if (keystorePropertiesFile.exists()) { + keystorePropertiesFile.inputStream().use { keystoreProperties.load(it) } +} + +android { + namespace = "com.dailyanchor.focusseed" + compileSdk = 35 + ndkVersion = "27.0.12077973" + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11.toString() + } + + defaultConfig { + applicationId = "com.dailyanchor.focusseed" + minSdk = 26 + targetSdk = 35 + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + signingConfigs { + create("release") { + keyAlias = keystoreProperties["keyAlias"] as String + keyPassword = keystoreProperties["keyPassword"] as String + storeFile = rootProject.file(keystoreProperties["storeFile"] as String) + storePassword = keystoreProperties["storePassword"] as String + } + } + + buildTypes { + release { + signingConfig = signingConfigs.getByName("release") + } + } +} + +flutter { + source = "../.." +} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..7232800 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/dailyanchor/focusseed/MainActivity.kt b/android/app/src/main/kotlin/com/dailyanchor/focusseed/MainActivity.kt new file mode 100644 index 0000000..6498b4e --- /dev/null +++ b/android/app/src/main/kotlin/com/dailyanchor/focusseed/MainActivity.kt @@ -0,0 +1,5 @@ +package com.dailyanchor.focusseed + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..b63b8a6 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..53374fe Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..e2825f8 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..7acf534 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..e4a7e57 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..89176ef --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,21 @@ +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/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..f018a61 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +android.enableJetifier=true diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..13372ae Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..afa1e8e --- /dev/null +++ b/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-8.10.2-all.zip diff --git a/android/gradlew b/android/gradlew new file mode 100644 index 0000000..9d82f78 --- /dev/null +++ b/android/gradlew @@ -0,0 +1,160 @@ +#!/usr/bin/env bash + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn ( ) { + echo "$*" +} + +die ( ) { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; +esac + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules +function splitJvmOpts() { + JVM_OPTS=("$@") +} +eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS +JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" + +exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..8a0b282 --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,90 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windowz variants + +if not "%OS%" == "Windows_NT" goto win9xME_args +if "%@eval[2+2]" == "4" goto 4NT_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* +goto execute + +:4NT_args +@rem Get arguments from the 4NT Shell from JP Software +set CMD_LINE_ARGS=%$ + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..a439442 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,25 @@ +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 "8.7.0" apply false + id("org.jetbrains.kotlin.android") version "1.8.22" apply false +} + +include(":app") diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..7c56964 --- /dev/null +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 12.0 + + diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..5c100d9 --- /dev/null +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,616 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.dailyanchor.focusSeed; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.dailyanchor.focusSeed.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.dailyanchor.focusSeed.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.dailyanchor.focusSeed.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.dailyanchor.focusSeed; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.dailyanchor.focusSeed; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..15cada4 --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..6266644 --- /dev/null +++ b/ios/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..dc9ada4 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..7353c41 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..6ed2d93 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..4cd7b00 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..fe73094 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..321773c Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..502f463 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..e9f5fea Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..84ac32a Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..8953cba Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..0467bf1 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist new file mode 100644 index 0000000..5c518eb --- /dev/null +++ b/ios/Runner/Info.plist @@ -0,0 +1,49 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Focus Seed + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + focus_seed + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + + diff --git a/ios/Runner/Runner-Bridging-Header.h b/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/launch/architecture.md b/launch/architecture.md new file mode 100644 index 0000000..8c4f941 --- /dev/null +++ b/launch/architecture.md @@ -0,0 +1,84 @@ +# Focus Seed Architecture + +Status: refactored from single-file prototype into modular Flutter structure. + +## Directory Layout + +```text +lib/ + main.dart + src/ + app/ + focus_seed_app.dart + app_theme.dart + core/ + date_keys.dart + iterable_extensions.dart + json_utils.dart + data/ + focus_seed_store.dart + models/ + app_state.dart + focus_session.dart + habit.dart + mood.dart + reflection_entry.dart + services/ + analytics_provider.dart + monetization_provider.dart + features/ + shell/ + focus_seed_home.dart + home/ + home_screen.dart + timer/ + timer_screen.dart + habits/ + habits_screen.dart + reflection/ + reflection_screen.dart + settings/ + settings_screen.dart + widgets/ + app_scaffold.dart + dialogs.dart + empty_state.dart + habit_row.dart + inline_editable_text.dart + loading_view.dart + metric_tile.dart + section_label.dart + seed_card.dart + settings_tile.dart + weekly_progress.dart +``` + +## Responsibility Boundaries + +- `main.dart`: process entry only. +- `src/app`: app shell and theme. +- `src/models`: serializable domain objects. +- `src/data`: persistence boundary using `shared_preferences`. +- `src/services`: no-op interfaces for analytics and monetization. +- `src/features/shell`: state orchestration and feature routing. +- `src/features/*`: screen-level UI and user flows. +- `src/widgets`: reusable UI components and dialogs. +- `src/core`: framework-light utilities. + +## Current Design Decisions + +- No real ad SDK in V1. +- No real billing SDK in V1. +- No analytics SDK in V1. +- No network dependency. +- JSON import/export uses copy/paste to avoid file permissions. +- Shell owns state until a future state-management package is justified. + +## Upgrade Path + +Next modularization steps when needed: + +- Move `src/app`, `src/core`, `src/models`, `src/services`, and `src/widgets` into shared packages. +- Replace shell state with Riverpod once multiple apps/features share the same state patterns. +- Add real `AnalyticsProvider`, `MonetizationProvider`, and remote config implementations only with privacy/Data safety updates. +- Add golden tests for key screens before scaling the app factory. diff --git a/launch/play-console/closed-testing-plan.md b/launch/play-console/closed-testing-plan.md new file mode 100644 index 0000000..73e016f --- /dev/null +++ b/launch/play-console/closed-testing-plan.md @@ -0,0 +1,206 @@ +# Closed Testing Plan + +Status: Required for this personal developer account before production access. + +Official baseline: + +- Minimum 12 testers. +- Testers must opt in to the closed test. +- At least 12 testers must remain opted in for 14 continuous days before applying for production access. +- Internal testing is optional and fast, but it does not replace the required closed test. + +Official references: + +- https://support.google.com/googleplay/android-developer/answer/14151465 +- https://support.google.com/googleplay/android-developer/answer/9845334 + +## Immediate Flow + +1. Finish App setup. + - Privacy policy URL + - App access: no login required + - Ads: no ads + - Content rating + - Target audience + - Data safety + - Government app: no + - Financial products and services: no + - Health: no + - App category and contact details + - Store listing + +2. Publish an internal testing release. + - Upload `build/app/outputs/bundle/release/app-release.aab`. + - Use release name `1.0.0 internal`. + - Release notes: + ```text + First internal test release. + Includes focus timer, habit tracking, daily reflection, local progress stats, JSON export/import, and local privacy controls. + ``` + - Add yourself and one or two trusted accounts if available. + - Use this to confirm Play delivery and installation. + +3. Create a closed testing track. + - Play Console > Testing > Closed testing. + - Create track name: `Focus Seed V1 closed test`. + - Countries/regions: use English-first regions from `region-localization-strategy.md`. + - Upload the same AAB. + - Release name: `1.0.0 closed test`. + - Release notes: + ```text + First closed test release for Focus Seed. + Please test the Today, Timer, Habits, Reflect, Settings, Export JSON, Import JSON, and Clear local data flows. + ``` + +4. Add testers. + - Use email list or Google Group. + - Recommended target: 15-20 invited testers, not exactly 12. + - Minimum passing target: 12 opted-in testers. + - Tester emails must be Google accounts that can access Google Play. + - Adding emails is not enough. Each tester must open the opt-in link and join the test. + - If uploading CSV, use one email address per line with no comma and no header row. Use `tester-email-list-no-header.csv` as the safer template. + +5. Send tester instructions. + - Send the opt-in link from Play Console. + - Ask each tester to confirm after they see the "You're a tester" or equivalent joined state. + - Ask each tester to install and open the app at least once. + +6. Track 14 days. + - Day 0 starts when at least 12 testers have opted in. + - Keep at least 12 opted in continuously for 14 days. + - If the opted-in count drops below 12, recruit replacements immediately and expect the qualifying window may be affected. + +7. Apply for production access. + - Apply from the Play Console dashboard when eligible. + - Answer questions using the notes in this file. + +## 12 Tester Strategy For A Solo Developer + +Best compliant sources: + +- Friends and family with Android phones and Google accounts. +- Former classmates, coworkers, or community contacts. +- Small indie developer groups where members exchange real testing help. +- Productivity/focus communities where you honestly ask for feedback. +- Existing users from your personal network. + +Recommended count: + +- Invite 20 people. +- Aim for 15 opted-in. +- Keep a buffer above 12 because some people will forget, use the wrong account, or fail to install. + +Do not: + +- Use fake Google accounts. +- Buy bot installs. +- Promise paid reviews or fake ratings. +- Ask testers to leave public reviews during closed testing. +- Misrepresent who tested or what feedback you received. + +Tester success checklist: + +- Tester email is added to the closed test email list or Google Group. +- Tester opens the opt-in link while signed into the same Google account. +- Tester joins the test. +- Tester installs from Google Play. +- Tester opens the app. +- Tester tries at least one real flow. +- Tester sends short feedback privately. + +## Tester Invitation Message - Chinese + +```text +我在 Google Play 上做一个新的 Android 应用封闭测试,名字是 Focus Seed。 +它是一个轻量的专注计时、习惯追踪和每日反思工具,不需要账号、不收费、没有广告。 + +能麻烦你帮我测试一下吗? + +步骤: +1. 用你的 Android 手机打开这个测试链接: + [粘贴 Play Console opt-in 链接] +2. 确认加入测试。 +3. 从 Google Play 安装 Focus Seed。 +4. 打开 App,试一下: + - Today 页面设置一个今日重点 + - Timer 页面启动或完成一次专注 + - Habits 页面勾选一个习惯 + - Reflect 页面写一句反思 + - Settings 页面看一下隐私/导出入口 +5. 测完后告诉我: + - 是否能正常安装和打开 + - 哪个页面最不清楚 + - 有没有崩溃、卡顿、文字看不清或按钮点不了 + +这个测试需要保持加入状态 14 天。你不需要每天使用,但请不要退出测试。 +谢谢! +``` + +## Tester Invitation Message - English + +```text +I am running a closed Google Play test for a new Android app called Focus Seed. +It is a lightweight focus timer, habit tracker, and one-minute reflection app. It does not require an account, does not include ads, and does not include payments. + +Could you help me test it? + +Steps: +1. Open this test link on your Android phone: + [Paste Play Console opt-in link] +2. Join the test. +3. Install Focus Seed from Google Play. +4. Try these flows: + - Set a daily anchor on Today + - Start or complete a focus timer + - Check off a habit + - Save a short reflection + - Open Settings and review privacy/export controls +5. Send me quick feedback: + - Did install/open work? + - Was any screen confusing? + - Any crash, lag, unreadable text, or button issue? + +The test needs testers to stay opted in for 14 days. You do not need to use it every day, but please do not leave the test during that period. +Thank you! +``` + +## Quick Feedback Questions + +Send these as a message or simple form: + +```text +1. Your phone model: +2. Android version, if known: +3. Did the app install from Google Play? Yes / No +4. Did the app open successfully? Yes / No +5. Which screen did you try? Today / Timer / Habits / Reflect / Settings +6. Any crash, freeze, lag, or layout issue? +7. What felt confusing? +8. One thing you would improve: +``` + +## Tester Tracking Table + +Use a spreadsheet or notes app: + +```text +Tester name | Google account email | Invited | Opted in | Installed | Opened app | Feedback received | Still opted in day 14 +``` + +Target: + +- Invited: 20 +- Opted in: 15+ +- Installed/opened: 12+ +- Feedback: 8+ + +## Production Access Application Notes + +Use these points when Play Console asks about the closed test: + +- App purpose: Focus Seed is a private focus timer, habit tracker, and daily reflection app. +- Test scope: Today anchor, timer, habits, reflection, settings, export/import, and local data deletion. +- Tester recruitment: invited trusted Android users through direct email/message using the Play Console opt-in link. +- Feedback collected: installation success, first-run clarity, timer usability, habit check-off, reflection save flow, settings/privacy clarity, layout or crash reports. +- Actions taken: fixed native Android launch package path, removed unfinished ads/subscription placeholders from settings, verified release build on Android 14. +- Readiness: no account, no server, no ads, no billing, no analytics SDK, no sensitive permissions, privacy policy and Data safety match the shipped app. diff --git a/launch/play-console/data-safety-guide.md b/launch/play-console/data-safety-guide.md new file mode 100644 index 0000000..8c78273 --- /dev/null +++ b/launch/play-console/data-safety-guide.md @@ -0,0 +1,30 @@ +# Data Safety Guidance + +This guidance assumes the submitted build is the current Focus Seed V1: + +- No account. +- No server. +- No ad SDK. +- No analytics SDK. +- No billing SDK. +- No network permission. +- Data stored locally with `shared_preferences`. +- Export/import is user-controlled copy/paste JSON. + +## Suggested Data Safety Answers + +- Does your app collect or share any of the required user data types? No, if no SDKs are added before upload. +- Is all user data collected by your app encrypted in transit? Not applicable for V1 because the app does not transmit user data. +- Do users have a way to request that their data is deleted? Yes. Users can clear local data in app settings or clear/uninstall app storage. +- Does your app share user data with third parties? No, if no SDKs are added before upload. + +## App Content Notes + +- Ads: No. +- In-app purchases: No. +- Target audience: Adults/general productivity users. Do not mark as children-directed. +- Content rating: Productivity app, no UGC, no violence, no medical/financial claims. + +## Important + +If you add ads, analytics, cloud sync, billing, crash reporting, file upload, or account sign-in before uploading, update this guidance, the privacy policy, and Play Console Data safety answers. diff --git a/launch/play-console/region-localization-strategy.md b/launch/play-console/region-localization-strategy.md new file mode 100644 index 0000000..8354589 --- /dev/null +++ b/launch/play-console/region-localization-strategy.md @@ -0,0 +1,128 @@ +# Region And Localization Strategy + +Status: V1 launch guardrail for Focus Seed. + +## Principle + +Focus Seed is intended to become a global productivity app, but V1 should not open every country by default. The first release is for account rescue, policy safety, and product validation. Availability, language, privacy wording, and monetization should expand by region only when the app has the right local readiness. + +Google Play country targeting is based on the user's Play country, not the user's current physical location. Internal testing is the exception: internal testers can receive the app even if their country is not selected for production/open/closed tracks. + +## V1 Recommended Launch Scope + +Use English-only store listing and app UI. + +Recommended first production or closed-test countries/regions: + +- United States +- Canada +- Australia +- New Zealand +- Singapore +- India +- Philippines +- Malaysia + +Rationale: + +- English is broadly usable in these markets. +- The app is culturally neutral: focus timer, habit tracking, local reflection. +- V1 has no account, no server, no ads, no analytics, no billing, and no sensitive permissions. +- This avoids adding rushed EU/EEA, Korean, Japanese, or other local-language obligations during the dormant-account rescue window. + +If only internal testing is used for urgent account activity, do not over-optimize regions yet. Upload the AAB, finish required declarations, and keep the country plan ready for the first closed/open/production rollout. + +## Regions To Defer + +Defer EU / EEA until a later release unless there is a clear need: + +- Add EU/EEA privacy wording and legal review. +- Confirm trader / non-trader presentation requirements shown in Play Console. +- Review geo-blocking and country availability choices. +- Confirm consent flow if ads, analytics, remote config, crash reporting, or cloud sync are added later. + +Defer United Kingdom and Switzerland if the goal is the simplest possible legal surface. They are English-friendly but should still get region-specific privacy review before paid or data-collecting features. + +Defer South Korea: + +- No Korean localization in V1. +- Review local rating, local store expectations, and translated listing before launch. + +Defer Japan: + +- No Japanese localization in V1. +- Productivity apps can work there, but store conversion and support quality need Japanese copy. + +Defer Brazil / LATAM: + +- Add Portuguese or Spanish listing and privacy/support wording first. + +Defer Middle East / North Africa: + +- Keep the app culturally neutral, but review local listing language and screenshots before expansion. + +## Store Listing Language + +V1: + +- Default language: English (United States). +- Do not add machine-translated Korean, Japanese, German, French, Spanish, or Portuguese just to look global. +- Do not claim local-language support that the app does not provide. + +Wave 2: + +- English variants for UK/Australia only if those regions are opened. +- Spanish or Portuguese only after translated screenshots and support text exist. + +Wave 3: + +- Japanese and Korean only after native-quality listing, screenshots, and cultural QA. + +## Privacy And Data Safety + +V1 privacy posture: + +- No account. +- No server. +- No analytics SDK. +- No ad SDK. +- No billing SDK. +- No network permission. +- Local-only user-controlled data. + +Even when no data is collected, Google Play still requires accurate Data safety answers and a privacy policy for published/testing apps outside internal-only use. Do not use region selection as an excuse to make inaccurate privacy statements. + +If any of the following are added, revisit every target region before release: + +- Ads +- Analytics +- Crash reporting +- Remote config +- Billing or subscriptions +- Cloud backup/sync +- Login/account creation +- Push notifications +- Location, contacts, calendar, storage, health, finance, or other sensitive data + +## Culture And Content Guardrails + +Keep V1 content neutral: + +- No religious, political, medical, financial, gambling, dating, or adult claims. +- No children-directed positioning. +- No culturally specific imagery or slogans in screenshots. +- No productivity claims that imply clinical treatment or guaranteed outcomes. +- Use plain, calm English and avoid idioms that translate poorly. + +## Expansion Checklist + +Before adding a new region: + +1. Confirm the app UI language and store listing language fit the region. +2. Confirm privacy policy and support email wording fit the region. +3. Confirm Data safety answers still match the shipped build. +4. Confirm content rating and target audience are appropriate. +5. Confirm screenshots do not include culturally sensitive text or imagery. +6. Confirm ads/subscriptions, if added, are available and priced correctly for that country. +7. Run at least one smoke test for the exact build being released. + diff --git a/launch/play-console/release-checklist.md b/launch/play-console/release-checklist.md new file mode 100644 index 0000000..91cde0f --- /dev/null +++ b/launch/play-console/release-checklist.md @@ -0,0 +1,109 @@ +# Release Checklist + +## Dormant Account Rescue Priority + +Google Play warning shown by account owner: + +- Risk: developer account may be closed because it is dormant / unused. +- Fix deadline: 2026-06-14. +- Current date when recorded: 2026-06-09. + +Google's official inactive-account guidance says the account owner should complete both actions before the deadline: + +1. Verify Play developer account contact email and phone number. +2. Create and publish an app, publish an update, or safely upload through internal testing / closed testing if the app is not ready for public production. + +For this project, the fastest low-risk route is: + +1. Verify account email and phone in Play Console account details. +2. Create the Focus Seed app record. +3. Upload the signed AAB to internal testing first. +4. Complete required App content sections: privacy policy, Data safety, content rating, target audience, ads declaration, app access. +5. Add store listing text, screenshots, icon, and feature graphic. +6. Send changes for review as soon as Play Console allows submission. +7. If production access is not available, keep the internal/closed testing upload as the activity evidence and follow any Play Console testing requirement shown in the account. + +Do not create a new developer account while this warning is active. Resolve this account directly. + +## Required Closed Test For This Account + +Play Console indicates production access is locked for this personal developer account. + +- Internal testing is optional and fast, but does not unlock production access. +- Closed testing is required before applying for production. +- Minimum: 12 opted-in testers for at least 14 continuous days. +- Recommended: invite 20 people and aim for 15+ opted-in testers to keep a buffer. +- Use `closed-testing-plan.md` for tester recruitment messages, checklist, tracking table, and production access application notes. + +## Region And Localization Gate + +Use `region-localization-strategy.md` before selecting countries/regions in Play Console. + +V1 recommendation: + +- Do not select all countries by default. +- Use English-only listing and UI. +- For production or closed testing, start with English-first countries/regions: United States, Canada, Australia, New Zealand, Singapore, India, Philippines, and Malaysia. +- Defer EU/EEA, United Kingdom, Switzerland, South Korea, Japan, Brazil/LATAM, and MENA until the matching privacy wording, localization, cultural QA, and monetization checks are complete. +- If only internal testing is used for urgent account rescue, upload first; internal testing is not constrained the same way as production/open/closed country targeting. + +## Build Evidence + +- App Bundle: `build/app/outputs/bundle/release/app-release.aab` +- Size: about 21.1 MB +- Version: `1.0.0+1` +- Package ID: `com.dailyanchor.focusseed` +- minSdk: 26 +- targetSdk: 35 +- Release signing: local upload key configured +- 512x512 Play listing icon: `launch/screenshots/focus-seed-icon-512-play.png` + +## Verification Completed + +- `flutter analyze`: passed +- `flutter test`: passed +- `flutter build appbundle --release`: passed +- Modular architecture refactor: passed analyze/test/build after splitting `main.dart` into app, models, data, services, features, and widgets. +- Web visual smoke test: passed for Today and Timer screens. +- Android release smoke test: passed on MT2110 / Android 14 after fixing native `MainActivity` package path to match `com.dailyanchor.focusseed`. +- Real device screenshots captured: `device-01-today.png`, `device-02-timer.png`, `device-03-habits.png`, `device-04-reflect.png`, `device-05-settings-clean.png`. +- V1 settings screen hides unfinished ads/subscription placeholders; monetization remains code-level no-op infrastructure only. +- Manifest reviewed: no INTERNET, AD_ID, location, camera, contacts, SMS, or storage permissions. +- V1 contains no real ad, billing, analytics, or remote config SDK. + +## Manifest Notes + +Merged manifest includes: + +- `android:minSdkVersion="26"` +- `android:targetSdkVersion="35"` +- Flutter text processing query +- AndroidX signature-level dynamic receiver permission + +No dangerous user-data permissions are declared. + +## Before Upload + +1. Publish the privacy policy at a public URL. +2. Confirm support email `leavysong@gmail.com` is correct and accessible. +3. Add the privacy policy URL in Play Console. +4. Confirm account eligibility: + - If production access exists, upload to internal testing first, then production. + - If this is a new personal developer account created after 2023-11-13, create a closed test with at least 12 testers for 14 days before production access. +5. Prepare real screenshots from the app. +6. Replace launcher icon if desired. +7. Choose countries/regions using `region-localization-strategy.md`. +8. Keep `android/focus-seed-upload.jks` and `android/key.properties` safe. You need the same upload key for future updates. + +## Upload Steps + +1. Create app in Play Console. +2. Fill app details and App content sections. +3. Upload `app-release.aab`. +4. Complete Data safety using `data-safety-guide.md`. +5. Complete content rating questionnaire. +6. Add privacy policy URL. +7. Add store listing text from `store-listing.md`. +8. Select countries/regions according to the V1 region strategy. +9. Add screenshots and feature graphic. +10. Submit to internal testing first when possible. diff --git a/launch/play-console/store-listing.md b/launch/play-console/store-listing.md new file mode 100644 index 0000000..c119bb7 --- /dev/null +++ b/launch/play-console/store-listing.md @@ -0,0 +1,57 @@ +# Google Play Store Listing + +## App Name + +Focus Seed + +## Short Description + +Private focus timer, habit tracker, and one-minute daily reflection. + +## Full Description + +Focus Seed helps you turn each day into a small, trackable win. Choose today’s anchor task, start a focused timer, mark habits as done, and write a short reflection when you finish. + +Everything works locally on your device. No account is required, and your focus sessions, habits, and reflections stay on your phone unless you choose to export them. + +Features: + +- Simple 15, 25, and 45 minute focus timer +- Daily anchor task +- Habit tracking +- One-minute reflection log +- Mood tags +- Local 7-day progress view +- JSON export and import +- Clear local-data privacy controls + +Focus Seed is designed for people who want a lightweight productivity tool without accounts, feeds, or complicated setup. + +## Category + +Productivity + +## Developer / Support + +- Developer / brand: leavysong +- Support email: leavysong@gmail.com + +## Graphic Assets + +- Play listing icon: `launch/screenshots/focus-seed-icon-512-play.png` +- Feature graphic: `launch/screenshots/focus-seed-feature-graphic-1024x500.png` + +## Tags / Positioning Keywords + +focus timer, habit tracker, reflection, productivity, offline, private, daily routine + +## Release Notes + +First release: + +- Focus timer +- Habit tracker +- Daily reflection +- Local progress stats +- Local JSON export/import +- In-app privacy controls diff --git a/launch/play-console/upload-key-notes.md b/launch/play-console/upload-key-notes.md new file mode 100644 index 0000000..ea8ef6b --- /dev/null +++ b/launch/play-console/upload-key-notes.md @@ -0,0 +1,32 @@ +# Upload Key Notes + +An upload keystore was generated for this app: + +```text +android/focus-seed-upload.jks +``` + +Gradle signing config: + +```text +android/key.properties +``` + +Alias: + +```text +focusseed_upload +``` + +SHA-256 fingerprint: + +```text +BA:5D:72:B4:8F:03:E4:92:99:E1:01:9F:F7:E6:40:61:54:22:73:0D:6A:E1:C2:7B:34:E2:66:73:80:99:30:DA +``` + +Important: + +- Keep this keystore safe. +- Do not publish it publicly. +- Do not lose it after uploading the app; future updates need the same upload key unless you reset it in Play Console. +- For a real brand, you may regenerate the upload key before the first Play upload. diff --git a/launch/privacy/privacy-policy.html b/launch/privacy/privacy-policy.html new file mode 100644 index 0000000..9fdc47c --- /dev/null +++ b/launch/privacy/privacy-policy.html @@ -0,0 +1,100 @@ + + + + + + Focus Seed Privacy Policy + + + +
+
+

Focus Seed Privacy Policy

+

Effective date: June 9, 2026

+ +

Focus Seed is designed to work privately on your device.

+ +

Data We Collect

+

Focus Seed V1 does not collect personal data through a server, account system, advertising SDK, analytics SDK, or billing SDK.

+ +

Local Data Stored On Your Device

+

The app stores the following data locally on your device:

+
    +
  • Today anchor task
  • +
  • Habit names and completion dates
  • +
  • Focus session completion time and duration
  • +
  • Reflection notes and mood tags
  • +
  • Basic app state needed for the app to work
  • +
+ +

Data Sharing

+

Focus Seed V1 does not share your app data with the developer or third parties.

+ +

Export And Import

+

You can export your local data as JSON from the app settings. You control where that exported text is copied or stored. You can also paste a previous JSON export into the app to import it.

+ +

Data Deletion

+

You can clear local app data from Settings. You can also remove data by uninstalling the app or clearing app storage in Android settings.

+ +

Ads, Analytics, And Purchases

+

Focus Seed V1 does not include advertising SDKs, analytics SDKs, or in-app purchase SDKs.

+ +

Children

+

Focus Seed is not directed to children.

+ +

Changes

+

If this policy changes, the updated version will be made available in the app listing and, where appropriate, inside the app.

+ +

Contact

+

Contact email: leavysong@gmail.com

+
+
+ + diff --git a/launch/privacy/privacy-policy.md b/launch/privacy/privacy-policy.md new file mode 100644 index 0000000..8578017 --- /dev/null +++ b/launch/privacy/privacy-policy.md @@ -0,0 +1,47 @@ +# Privacy Policy + +Effective date: 2026-06-09 + +Focus Seed is designed to work privately on your device. + +## Data We Collect + +Focus Seed V1 does not collect personal data through a server, account system, advertising SDK, analytics SDK, or billing SDK. + +## Local Data Stored On Your Device + +The app stores the following data locally on your device: + +- Today anchor task +- Habit names and completion dates +- Focus session completion time and duration +- Reflection notes and mood tags +- Basic app state needed for the app to work + +## Data Sharing + +Focus Seed V1 does not share your app data with the developer or third parties. + +## Export And Import + +You can export your local data as JSON from the app settings. You control where that exported text is copied or stored. You can also paste a previous JSON export into the app to import it. + +## Data Deletion + +You can clear local app data from Settings. You can also remove data by uninstalling the app or clearing app storage in Android settings. + +## Ads, Analytics, And Purchases + +Focus Seed V1 does not include advertising SDKs, analytics SDKs, or in-app purchase SDKs. + +## Children + +Focus Seed is not directed to children. + +## Changes + +If this policy changes, the updated version will be made available in the app listing and, where appropriate, inside the app. + +## Contact + +Contact email: leavysong@gmail.com diff --git a/launch/product-brief.md b/launch/product-brief.md new file mode 100644 index 0000000..07d2239 --- /dev/null +++ b/launch/product-brief.md @@ -0,0 +1,64 @@ +# Focus Seed Product Brief + +Status: V1 release candidate + +## Positioning + +Focus Seed is a private, offline-first focus timer, habit tracker, and one-minute reflection app for people who want a quiet daily productivity loop without accounts, feeds, or setup friction. + +## V1 Scope + +- Today anchor task. +- 15 / 25 / 45 minute focus timer. +- Habit creation, rename, delete, and daily check-off. +- One-minute reflection with mood tag. +- 7-day progress pulse. +- Local JSON export and import by copy/paste. +- In-app privacy explanation. +- No-op monetization and analytics interfaces. +- English-only UI and store listing for the first launch wave. + +## V1 Exclusions + +- No account sign-in. +- No server. +- No cloud sync. +- No real ad SDK. +- No billing SDK. +- No analytics SDK. +- No push notifications. +- No sensitive permissions. +- No Korean, Japanese, EU/EEA-specific, or other regional localization in V1. + +## Region Strategy + +Focus Seed is designed as a global product, but V1 should launch in a controlled English-first set of markets instead of every country. + +Recommended first countries/regions for production or closed testing: + +- United States +- Canada +- Australia +- New Zealand +- Singapore +- India +- Philippines +- Malaysia + +Defer EU/EEA, United Kingdom, Switzerland, South Korea, Japan, Brazil/LATAM, and MENA until localization, privacy wording, cultural QA, and any monetization requirements are reviewed for those regions. + +## Future Revenue Path + +- V2/V3: optional low-interruption ads after retention validation. +- V4/V5: one-time unlock or subscription for advanced statistics, themes, export templates, widgets, and backup/sync. +- Free core loop remains usable. + +## App IDs + +- App name: Focus Seed +- Developer / brand: leavysong +- Support email: leavysong@gmail.com +- Package/application ID: `com.dailyanchor.focusseed` +- Version: `1.0.0+1` +- Android min SDK: 26 +- Android target SDK: 35 diff --git a/launch/releases/v1.0.0.md b/launch/releases/v1.0.0.md new file mode 100644 index 0000000..66ff443 --- /dev/null +++ b/launch/releases/v1.0.0.md @@ -0,0 +1,38 @@ +# Focus Seed 1.0.0 Release Record + +Release status: sealed Google Play testing build. + +## Identity + +- Version name: `1.0.0` +- Version code: `1` +- Package ID: `com.dailyanchor.focusseed` +- Git tag: `v1.0.0` +- Android min SDK: `26` +- Android target SDK: `35` + +## Artifact + +- Local path: `build/app/outputs/bundle/release/app-release.aab` +- Size: `22,168,710` bytes +- SHA-256: `2987C5403C88CA7A37FD05C5C77C285B650F21056450690360633242D6B92385` + +The AAB is intentionally not stored in Git. Verify any archived copy against this checksum before uploading or retaining it as the sealed 1.0.0 artifact. + +## Verification + +- `flutter analyze`: passed +- `flutter test`: passed +- `flutter build appbundle --release`: passed +- Android 14 release smoke test: passed +- Real-device screenshots captured +- Manifest permission review: passed + +## Privacy And Monetization + +- No account or server +- No ads or Advertising ID +- No billing or purchases +- No analytics or crash-reporting SDK +- No network permission +- Local-only user data with user-controlled export diff --git a/launch/screenshots/device-01-today.png b/launch/screenshots/device-01-today.png new file mode 100644 index 0000000..6e457c1 Binary files /dev/null and b/launch/screenshots/device-01-today.png differ diff --git a/launch/screenshots/device-02-timer.png b/launch/screenshots/device-02-timer.png new file mode 100644 index 0000000..0d34a95 Binary files /dev/null and b/launch/screenshots/device-02-timer.png differ diff --git a/launch/screenshots/device-03-habits.png b/launch/screenshots/device-03-habits.png new file mode 100644 index 0000000..581875b Binary files /dev/null and b/launch/screenshots/device-03-habits.png differ diff --git a/launch/screenshots/device-04-reflect.png b/launch/screenshots/device-04-reflect.png new file mode 100644 index 0000000..0336448 Binary files /dev/null and b/launch/screenshots/device-04-reflect.png differ diff --git a/launch/screenshots/device-05-settings-clean.png b/launch/screenshots/device-05-settings-clean.png new file mode 100644 index 0000000..2527801 Binary files /dev/null and b/launch/screenshots/device-05-settings-clean.png differ diff --git a/launch/screenshots/focus-seed-feature-graphic-1024x500.png b/launch/screenshots/focus-seed-feature-graphic-1024x500.png new file mode 100644 index 0000000..be4735a Binary files /dev/null and b/launch/screenshots/focus-seed-feature-graphic-1024x500.png differ diff --git a/launch/screenshots/focus-seed-icon-512-play.png b/launch/screenshots/focus-seed-icon-512-play.png new file mode 100644 index 0000000..e8878ec Binary files /dev/null and b/launch/screenshots/focus-seed-icon-512-play.png differ diff --git a/launch/screenshots/focus-seed-icon-512.png b/launch/screenshots/focus-seed-icon-512.png new file mode 100644 index 0000000..e81c0ba Binary files /dev/null and b/launch/screenshots/focus-seed-icon-512.png differ diff --git a/launch/screenshots/screenshot-plan.md b/launch/screenshots/screenshot-plan.md new file mode 100644 index 0000000..0e662d8 --- /dev/null +++ b/launch/screenshots/screenshot-plan.md @@ -0,0 +1,47 @@ +# Screenshot Plan + +Use real app screenshots from the release build. + +Captured on MT2110 / Android 14: + +- `device-01-today.png` +- `device-02-timer.png` +- `device-03-habits.png` +- `device-04-reflect.png` +- `device-05-settings-clean.png` + +Use these as the first Play Console screenshot candidates. Avoid older `device-05-settings.png` because it showed internal monetization placeholder wording that has now been removed from the app. + +Recommended phone screenshots: + +1. Today screen with anchor task and daily metrics. +2. Focus timer with progress ring. +3. Habits screen with completed and pending habits. +4. Reflection screen with mood tags and text area. +5. Settings screen showing local data export/import and privacy. + +Suggested captions: + +- Plan one clear task for today. +- Start a calm focus session. +- Track tiny habits without an account. +- Finish with a one-minute reflection. +- Keep your data local and export when needed. + +Feature graphic direction: + +- Size: 1024x500. +- Visual: calm green/teal background, Focus Seed name, simple phone mockup showing timer/progress. +- Copy: "Focus. Track. Reflect." +- Generated asset: `launch/screenshots/focus-seed-feature-graphic-1024x500.png` + +Icon: + +- Generated 512x512 asset: `launch/screenshots/focus-seed-icon-512.png` +- Play Console listing icon without transparent corners: `launch/screenshots/focus-seed-icon-512-play.png` + +Avoid: + +- Claims about medical benefit. +- Subscription or ad messaging in V1 screenshots. +- Screens that show fake cloud sync or features not in the app. diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..c52e96e --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,8 @@ +import 'package:flutter/material.dart'; + +import 'src/app/focus_seed_app.dart'; + +void main() { + WidgetsFlutterBinding.ensureInitialized(); + runApp(const FocusSeedApp()); +} diff --git a/lib/src/app/app_theme.dart b/lib/src/app/app_theme.dart new file mode 100644 index 0000000..002c900 --- /dev/null +++ b/lib/src/app/app_theme.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; + +class AppTheme { + static const seed = Color(0xFF2E7D6B); + static const accent = Color(0xFFF4B740); + static const paper = Color(0xFFF7F8F5); + + static ThemeData light() { + final scheme = ColorScheme.fromSeed(seedColor: seed, surface: paper); + return _base(scheme); + } + + static ThemeData dark() { + final scheme = ColorScheme.fromSeed( + seedColor: seed, + brightness: Brightness.dark, + ); + return _base(scheme); + } + + static ThemeData _base(ColorScheme scheme) { + return ThemeData( + useMaterial3: true, + colorScheme: scheme, + scaffoldBackgroundColor: scheme.surface, + navigationBarTheme: NavigationBarThemeData( + indicatorColor: scheme.primaryContainer, + labelTextStyle: WidgetStateProperty.resolveWith( + (_) => const TextStyle(fontSize: 12, fontWeight: FontWeight.w600), + ), + ), + cardTheme: CardTheme( + elevation: 0, + margin: EdgeInsets.zero, + color: scheme.surfaceContainerHighest.withValues(alpha: 0.55), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + minimumSize: const Size(48, 48), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + minimumSize: const Size(48, 48), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + ), + ); + } +} diff --git a/lib/src/app/focus_seed_app.dart b/lib/src/app/focus_seed_app.dart new file mode 100644 index 0000000..6f71154 --- /dev/null +++ b/lib/src/app/focus_seed_app.dart @@ -0,0 +1,19 @@ +import 'package:flutter/material.dart'; + +import '../features/shell/focus_seed_home.dart'; +import 'app_theme.dart'; + +class FocusSeedApp extends StatelessWidget { + const FocusSeedApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Focus Seed', + debugShowCheckedModeBanner: false, + theme: AppTheme.light(), + darkTheme: AppTheme.dark(), + home: const FocusSeedHome(), + ); + } +} diff --git a/lib/src/core/date_keys.dart b/lib/src/core/date_keys.dart new file mode 100644 index 0000000..f23782e --- /dev/null +++ b/lib/src/core/date_keys.dart @@ -0,0 +1,76 @@ +import '../models/focus_session.dart'; +import '../models/habit.dart'; +import '../models/reflection_entry.dart'; + +String todayKey() => dateKey(DateTime.now()); + +String dateKey(DateTime date) { + final local = DateTime(date.year, date.month, date.day); + return '${local.year.toString().padLeft(4, '0')}-' + '${local.month.toString().padLeft(2, '0')}-' + '${local.day.toString().padLeft(2, '0')}'; +} + +String readableToday() { + const months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + final now = DateTime.now(); + return '${months[now.month - 1]} ${now.day}, ${now.year}'; +} + +List lastSevenDateKeys() { + final now = DateTime.now(); + return List.generate( + 7, + (index) => dateKey(now.subtract(Duration(days: 6 - index))), + ); +} + +int minutesForDate(List sessions, String key) { + return sessions + .where((session) => dateKey(session.completedAt) == key) + .fold(0, (total, session) => total + session.minutes); +} + +int currentStreak( + List habits, + List sessions, + List reflections, +) { + var streak = 0; + var cursor = DateTime.now(); + while (true) { + final key = dateKey(cursor); + final hasHabit = habits.any((habit) => habit.completedDates.contains(key)); + final hasSession = sessions.any( + (session) => dateKey(session.completedAt) == key, + ); + final hasReflection = reflections.any( + (reflection) => reflection.dateKey == key, + ); + if (!hasHabit && !hasSession && !hasReflection) { + return streak; + } + streak += 1; + cursor = cursor.subtract(const Duration(days: 1)); + } +} + +String formatSeconds(int seconds) { + final minutes = seconds ~/ 60; + final remaining = seconds % 60; + return '${minutes.toString().padLeft(2, '0')}:' + '${remaining.toString().padLeft(2, '0')}'; +} diff --git a/lib/src/core/iterable_extensions.dart b/lib/src/core/iterable_extensions.dart new file mode 100644 index 0000000..a925576 --- /dev/null +++ b/lib/src/core/iterable_extensions.dart @@ -0,0 +1,9 @@ +extension FirstOrNull on Iterable { + T? get firstOrNull { + final iterator = this.iterator; + if (iterator.moveNext()) { + return iterator.current; + } + return null; + } +} diff --git a/lib/src/core/json_utils.dart b/lib/src/core/json_utils.dart new file mode 100644 index 0000000..170cdeb --- /dev/null +++ b/lib/src/core/json_utils.dart @@ -0,0 +1,5 @@ +import 'dart:convert'; + +String prettyJson(Map value) { + return const JsonEncoder.withIndent(' ').convert(value); +} diff --git a/lib/src/data/focus_seed_store.dart b/lib/src/data/focus_seed_store.dart new file mode 100644 index 0000000..b284aff --- /dev/null +++ b/lib/src/data/focus_seed_store.dart @@ -0,0 +1,33 @@ +import 'dart:convert'; + +import 'package:shared_preferences/shared_preferences.dart'; + +import '../core/json_utils.dart'; +import '../models/app_state.dart'; + +class FocusSeedStore { + static const _storageKey = 'focus_seed_state_v1'; + + Future load() async { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_storageKey); + if (raw == null || raw.isEmpty) { + return AppState.starter(); + } + try { + return AppState.fromJson(jsonDecode(raw) as Map); + } on Object { + return AppState.starter(); + } + } + + Future save(AppState state) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_storageKey, prettyJson(state.toJson())); + } + + Future clear() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_storageKey); + } +} diff --git a/lib/src/features/habits/habits_screen.dart b/lib/src/features/habits/habits_screen.dart new file mode 100644 index 0000000..06b065b --- /dev/null +++ b/lib/src/features/habits/habits_screen.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; + +import '../../core/date_keys.dart'; +import '../../models/app_state.dart'; +import '../../models/habit.dart'; +import '../../widgets/app_scaffold.dart'; +import '../../widgets/dialogs.dart'; +import '../../widgets/empty_state.dart'; +import '../../widgets/habit_row.dart'; + +class HabitsScreen extends StatelessWidget { + const HabitsScreen({ + required this.state, + required this.onAddHabit, + required this.onRenameHabit, + required this.onDeleteHabit, + required this.onToggleHabit, + super.key, + }); + + final AppState state; + final ValueChanged onAddHabit; + final void Function(Habit habit, String title) onRenameHabit; + final ValueChanged onDeleteHabit; + final ValueChanged onToggleHabit; + + @override + Widget build(BuildContext context) { + final today = todayKey(); + return AppScaffold( + title: 'Habits', + subtitle: 'Keep the list small and repeatable.', + action: IconButton.filledTonal( + tooltip: 'Add habit', + onPressed: () => showHabitDialog(context, onAddHabit), + icon: const Icon(Icons.add), + ), + child: ListView( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 24), + children: [ + if (state.habits.isEmpty) + EmptyState( + icon: Icons.checklist_outlined, + title: 'No habits yet', + action: 'Add habit', + onAction: () => showHabitDialog(context, onAddHabit), + ) + else + ...state.habits.map( + (habit) => HabitRow( + habit: habit, + dateKey: today, + onToggle: () => onToggleHabit(habit), + onEdit: + () => showHabitDialog( + context, + (title) => onRenameHabit(habit, title), + initialValue: habit.title, + ), + onDelete: + () => + confirmDeleteHabit(context, () => onDeleteHabit(habit)), + ), + ), + ], + ), + ); + } +} diff --git a/lib/src/features/home/home_screen.dart b/lib/src/features/home/home_screen.dart new file mode 100644 index 0000000..ffa0a5f --- /dev/null +++ b/lib/src/features/home/home_screen.dart @@ -0,0 +1,147 @@ +import 'package:flutter/material.dart'; + +import '../../app/app_theme.dart'; +import '../../core/date_keys.dart'; +import '../../models/app_state.dart'; +import '../../models/habit.dart'; +import '../../widgets/app_scaffold.dart'; +import '../../widgets/dialogs.dart'; +import '../../widgets/empty_state.dart'; +import '../../widgets/habit_row.dart'; +import '../../widgets/inline_editable_text.dart'; +import '../../widgets/metric_tile.dart'; +import '../../widgets/section_label.dart'; +import '../../widgets/seed_card.dart'; +import '../../widgets/weekly_progress.dart'; + +class HomeScreen extends StatelessWidget { + const HomeScreen({ + required this.state, + required this.onAnchorChanged, + required this.onOpenTimer, + required this.onToggleHabit, + required this.onAddHabit, + super.key, + }); + + final AppState state; + final ValueChanged onAnchorChanged; + final VoidCallback onOpenTimer; + final ValueChanged onToggleHabit; + final ValueChanged onAddHabit; + + @override + Widget build(BuildContext context) { + final today = todayKey(); + final completed = + state.habits + .where((habit) => habit.completedDates.contains(today)) + .length; + final focusMinutes = minutesForDate(state.sessions, today); + final streak = currentStreak( + state.habits, + state.sessions, + state.reflections, + ); + + return AppScaffold( + title: 'Focus Seed', + subtitle: readableToday(), + child: ListView( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 24), + children: [ + SeedCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SectionLabel( + icon: Icons.flag_outlined, + text: 'Today anchor', + ), + const SizedBox(height: 12), + InlineEditableText( + value: state.anchor, + emptyText: 'Choose one important task', + onSubmitted: onAnchorChanged, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: onOpenTimer, + icon: const Icon(Icons.play_arrow_rounded), + label: const Text('Start focus'), + ), + ], + ), + ), + const SizedBox(height: 14), + Row( + children: [ + Expanded( + child: MetricTile( + label: 'Focus', + value: '$focusMinutes', + suffix: 'min', + icon: Icons.timer_outlined, + ), + ), + const SizedBox(width: 10), + Expanded( + child: MetricTile( + label: 'Habits', + value: '$completed/${state.habits.length}', + suffix: '', + icon: Icons.check_circle_outline, + ), + ), + const SizedBox(width: 10), + Expanded( + child: MetricTile( + label: 'Streak', + value: '$streak', + suffix: 'days', + icon: Icons.local_fire_department_outlined, + accent: AppTheme.accent, + ), + ), + ], + ), + const SizedBox(height: 18), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Today habits', + style: Theme.of(context).textTheme.titleMedium, + ), + IconButton.filledTonal( + tooltip: 'Add habit', + onPressed: () => showHabitDialog(context, onAddHabit), + icon: const Icon(Icons.add), + ), + ], + ), + const SizedBox(height: 8), + if (state.habits.isEmpty) + EmptyState( + icon: Icons.checklist_rtl, + title: 'Add one tiny habit', + action: 'Add habit', + onAction: () => showHabitDialog(context, onAddHabit), + ) + else + ...state.habits + .take(5) + .map( + (habit) => HabitRow( + habit: habit, + dateKey: today, + onToggle: () => onToggleHabit(habit), + ), + ), + const SizedBox(height: 18), + WeeklyProgress(state: state), + ], + ), + ); + } +} diff --git a/lib/src/features/reflection/reflection_screen.dart b/lib/src/features/reflection/reflection_screen.dart new file mode 100644 index 0000000..c5ad99d --- /dev/null +++ b/lib/src/features/reflection/reflection_screen.dart @@ -0,0 +1,123 @@ +import 'package:flutter/material.dart'; + +import '../../core/date_keys.dart'; +import '../../core/iterable_extensions.dart'; +import '../../models/app_state.dart'; +import '../../models/mood.dart'; +import '../../widgets/app_scaffold.dart'; +import '../../widgets/section_label.dart'; +import '../../widgets/seed_card.dart'; + +class ReflectionScreen extends StatefulWidget { + const ReflectionScreen({ + required this.state, + required this.onSave, + super.key, + }); + + final AppState state; + final void Function(String note, Mood mood) onSave; + + @override + State createState() => _ReflectionScreenState(); +} + +class _ReflectionScreenState extends State { + final _controller = TextEditingController(); + Mood _mood = Mood.clear; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final today = todayKey(); + final todayReflection = + widget.state.reflections + .where((item) => item.dateKey == today) + .firstOrNull; + if (todayReflection != null && _controller.text.isEmpty) { + _controller.text = todayReflection.note; + _mood = todayReflection.mood; + } + + return AppScaffold( + title: 'Reflection', + subtitle: 'One sentence is enough.', + child: ListView( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 24), + children: [ + SeedCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SectionLabel( + icon: Icons.psychology_outlined, + text: 'How did it feel?', + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + children: + Mood.values + .map( + (mood) => ChoiceChip( + avatar: Icon(mood.icon, size: 18), + label: Text(mood.label), + selected: _mood == mood, + onSelected: (_) => setState(() => _mood = mood), + ), + ) + .toList(), + ), + const SizedBox(height: 14), + TextField( + controller: _controller, + minLines: 4, + maxLines: 7, + textCapitalization: TextCapitalization.sentences, + decoration: const InputDecoration( + border: OutlineInputBorder(), + hintText: 'What made today a little better?', + ), + ), + const SizedBox(height: 14), + FilledButton.icon( + onPressed: () => widget.onSave(_controller.text, _mood), + icon: const Icon(Icons.save_outlined), + label: const Text('Save reflection'), + ), + ], + ), + ), + const SizedBox(height: 18), + Text('Recent notes', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + if (widget.state.reflections.isEmpty) + const SeedCard( + child: Text( + 'Your reflections will appear here after you save one.', + ), + ) + else + ...widget.state.reflections.reversed + .take(7) + .map( + (entry) => SeedCard( + margin: const EdgeInsets.only(bottom: 10), + child: ListTile( + contentPadding: EdgeInsets.zero, + leading: Icon(entry.mood.icon), + title: Text(entry.note), + subtitle: Text(entry.dateKey), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/src/features/settings/settings_screen.dart b/lib/src/features/settings/settings_screen.dart new file mode 100644 index 0000000..af33108 --- /dev/null +++ b/lib/src/features/settings/settings_screen.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; + +import '../../models/app_state.dart'; +import '../../widgets/app_scaffold.dart'; +import '../../widgets/dialogs.dart'; +import '../../widgets/seed_card.dart'; +import '../../widgets/settings_tile.dart'; + +class SettingsScreen extends StatelessWidget { + const SettingsScreen({ + required this.state, + required this.onImport, + required this.onClear, + super.key, + }); + + final AppState state; + final ValueChanged onImport; + final VoidCallback onClear; + + @override + Widget build(BuildContext context) { + return AppScaffold( + title: 'Settings', + subtitle: 'Private by default. No account required.', + child: ListView( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 24), + children: [ + SeedCard( + child: Column( + children: [ + SettingsTile( + icon: Icons.ios_share, + title: 'Export JSON', + subtitle: 'Copy your local data as readable JSON.', + onTap: () => showExportDialog(context, state), + ), + const Divider(), + SettingsTile( + icon: Icons.file_upload_outlined, + title: 'Import JSON', + subtitle: 'Paste a previous Focus Seed export.', + onTap: () => showImportDialog(context, onImport), + ), + const Divider(), + SettingsTile( + icon: Icons.privacy_tip_outlined, + title: 'Privacy', + subtitle: + 'Your data stays on this device unless you export it.', + onTap: () => showPrivacySheet(context), + ), + ], + ), + ), + const SizedBox(height: 14), + SeedCard( + child: SettingsTile( + icon: Icons.delete_outline, + title: 'Clear local data', + subtitle: 'Reset habits, sessions, and reflections.', + destructive: true, + onTap: () => confirmClearData(context, onClear), + ), + ), + ], + ), + ); + } +} diff --git a/lib/src/features/shell/focus_seed_home.dart b/lib/src/features/shell/focus_seed_home.dart new file mode 100644 index 0000000..169e762 --- /dev/null +++ b/lib/src/features/shell/focus_seed_home.dart @@ -0,0 +1,269 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../core/date_keys.dart'; +import '../../data/focus_seed_store.dart'; +import '../../models/app_state.dart'; +import '../../models/focus_session.dart'; +import '../../models/habit.dart'; +import '../../models/mood.dart'; +import '../../models/reflection_entry.dart'; +import '../../services/analytics_provider.dart'; +import '../../widgets/dialogs.dart'; +import '../../widgets/loading_view.dart'; +import '../habits/habits_screen.dart'; +import '../home/home_screen.dart'; +import '../reflection/reflection_screen.dart'; +import '../settings/settings_screen.dart'; +import '../timer/timer_screen.dart'; + +class FocusSeedHome extends StatefulWidget { + const FocusSeedHome({super.key}); + + @override + State createState() => _FocusSeedHomeState(); +} + +class _FocusSeedHomeState extends State { + final _store = FocusSeedStore(); + final _analytics = const NoopAnalyticsProvider(); + AppState? _state; + int _tabIndex = 0; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + final state = await _store.load(); + if (!mounted) { + return; + } + setState(() => _state = state); + } + + Future _setStateAndSave(AppState state) async { + setState(() => _state = state); + await _store.save(state); + } + + @override + Widget build(BuildContext context) { + final state = _state; + return Scaffold( + body: AnimatedSwitcher( + duration: const Duration(milliseconds: 180), + child: + state == null + ? const LoadingView() + : SafeArea( + child: IndexedStack( + index: _tabIndex, + children: [ + HomeScreen( + state: state, + onAnchorChanged: + (anchor) => _setStateAndSave( + state.copyWith(anchor: anchor.trim()), + ), + onOpenTimer: () => setState(() => _tabIndex = 1), + onToggleHabit: _toggleHabit, + onAddHabit: _addHabit, + ), + TimerScreen( + onComplete: _completeFocusSession, + onReflect: () => setState(() => _tabIndex = 3), + ), + HabitsScreen( + state: state, + onAddHabit: _addHabit, + onRenameHabit: _renameHabit, + onDeleteHabit: _deleteHabit, + onToggleHabit: _toggleHabit, + ), + ReflectionScreen(state: state, onSave: _saveReflection), + SettingsScreen( + state: state, + onImport: _importState, + onClear: _clearData, + ), + ], + ), + ), + ), + bottomNavigationBar: + state == null + ? null + : NavigationBar( + selectedIndex: _tabIndex, + onDestinationSelected: (index) { + HapticFeedback.selectionClick(); + setState(() => _tabIndex = index); + }, + destinations: const [ + NavigationDestination( + icon: Icon(Icons.home_outlined), + selectedIcon: Icon(Icons.home), + label: 'Today', + ), + NavigationDestination( + icon: Icon(Icons.timer_outlined), + selectedIcon: Icon(Icons.timer), + label: 'Timer', + ), + NavigationDestination( + icon: Icon(Icons.check_circle_outline), + selectedIcon: Icon(Icons.check_circle), + label: 'Habits', + ), + NavigationDestination( + icon: Icon(Icons.edit_note_outlined), + selectedIcon: Icon(Icons.edit_note), + label: 'Reflect', + ), + NavigationDestination( + icon: Icon(Icons.settings_outlined), + selectedIcon: Icon(Icons.settings), + label: 'Settings', + ), + ], + ), + ); + } + + Future _toggleHabit(Habit habit) async { + final state = _state; + if (state == null) { + return; + } + final today = todayKey(); + final completedDates = Set.from(habit.completedDates); + if (completedDates.contains(today)) { + completedDates.remove(today); + } else { + completedDates.add(today); + await HapticFeedback.lightImpact(); + } + final habits = + state.habits + .map( + (item) => + item.id == habit.id + ? habit.copyWith(completedDates: completedDates) + : item, + ) + .toList(); + _analytics.track('habit_toggled'); + await _setStateAndSave(state.copyWith(habits: habits)); + } + + Future _addHabit(String title) async { + final state = _state; + final clean = title.trim(); + if (state == null || clean.isEmpty) { + return; + } + final habit = Habit( + id: 'habit-${DateTime.now().microsecondsSinceEpoch}', + title: clean, + createdAt: DateTime.now(), + completedDates: {}, + ); + _analytics.track('habit_created'); + await _setStateAndSave(state.copyWith(habits: [...state.habits, habit])); + } + + Future _renameHabit(Habit habit, String title) async { + final state = _state; + final clean = title.trim(); + if (state == null || clean.isEmpty) { + return; + } + final habits = + state.habits + .map( + (item) => + item.id == habit.id ? item.copyWith(title: clean) : item, + ) + .toList(); + await _setStateAndSave(state.copyWith(habits: habits)); + } + + Future _deleteHabit(Habit habit) async { + final state = _state; + if (state == null) { + return; + } + final habits = state.habits.where((item) => item.id != habit.id).toList(); + await _setStateAndSave(state.copyWith(habits: habits)); + } + + Future _completeFocusSession(int minutes) async { + final state = _state; + if (state == null) { + return; + } + final session = FocusSession( + id: 'session-${DateTime.now().microsecondsSinceEpoch}', + minutes: minutes, + completedAt: DateTime.now(), + ); + _analytics.track('focus_completed', {'minutes': minutes}); + await _setStateAndSave( + state.copyWith(sessions: [...state.sessions, session]), + ); + } + + Future _saveReflection(String note, Mood mood) async { + final state = _state; + final clean = note.trim(); + if (state == null || clean.isEmpty) { + return; + } + final today = todayKey(); + final entry = ReflectionEntry( + id: 'reflection-${DateTime.now().microsecondsSinceEpoch}', + dateKey: today, + note: clean, + mood: mood, + createdAt: DateTime.now(), + ); + final reflections = [ + ...state.reflections.where((item) => item.dateKey != today), + entry, + ]; + _analytics.track('reflection_saved'); + await _setStateAndSave(state.copyWith(reflections: reflections)); + if (mounted) { + showSnack(context, 'Reflection saved'); + } + } + + Future _importState(String raw) async { + try { + final imported = AppState.fromJson( + jsonDecode(raw) as Map, + ); + await _setStateAndSave(imported); + if (mounted) { + showSnack(context, 'Data imported'); + } + } on Object { + if (mounted) { + showSnack(context, 'Import failed. Check the JSON and try again.'); + } + } + } + + Future _clearData() async { + await _store.clear(); + await _setStateAndSave(AppState.starter()); + if (mounted) { + showSnack(context, 'Local data cleared'); + } + } +} diff --git a/lib/src/features/timer/timer_screen.dart b/lib/src/features/timer/timer_screen.dart new file mode 100644 index 0000000..82591ea --- /dev/null +++ b/lib/src/features/timer/timer_screen.dart @@ -0,0 +1,212 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../core/date_keys.dart'; +import '../../widgets/app_scaffold.dart'; +import '../../widgets/seed_card.dart'; + +class TimerScreen extends StatefulWidget { + const TimerScreen({ + required this.onComplete, + required this.onReflect, + super.key, + }); + + final ValueChanged onComplete; + final VoidCallback onReflect; + + @override + State createState() => _TimerScreenState(); +} + +class _TimerScreenState extends State { + static const _presets = [15, 25, 45]; + Timer? _timer; + int _selectedMinutes = 25; + int _remainingSeconds = 25 * 60; + bool _running = false; + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final totalSeconds = _selectedMinutes * 60; + final progress = 1 - (_remainingSeconds / totalSeconds); + return AppScaffold( + title: 'Focus timer', + subtitle: 'No ads or distractions here.', + child: ListView( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 24), + children: [ + SeedCard( + child: Column( + children: [ + Wrap( + alignment: WrapAlignment.center, + spacing: 8, + children: + _presets + .map( + (minutes) => ChoiceChip( + label: Text('$minutes min'), + selected: _selectedMinutes == minutes, + onSelected: + _running + ? null + : (_) => setState(() { + _selectedMinutes = minutes; + _remainingSeconds = minutes * 60; + }), + ), + ) + .toList(), + ), + const SizedBox(height: 30), + TweenAnimationBuilder( + tween: Tween(begin: 0, end: progress.clamp(0, 1)), + duration: const Duration(milliseconds: 240), + builder: + (context, value, child) => SizedBox.square( + dimension: 220, + child: Stack( + alignment: Alignment.center, + children: [ + CircularProgressIndicator( + value: value, + strokeWidth: 14, + strokeCap: StrokeCap.round, + backgroundColor: + Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + ), + child!, + ], + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + formatSeconds(_remainingSeconds), + style: Theme.of(context).textTheme.displayMedium + ?.copyWith(fontWeight: FontWeight.w800), + ), + Text( + _running ? 'Focus in progress' : 'Ready when you are', + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ), + const SizedBox(height: 28), + Row( + children: [ + Expanded( + child: FilledButton.icon( + onPressed: _running ? _pause : _start, + icon: Icon(_running ? Icons.pause : Icons.play_arrow), + label: Text(_running ? 'Pause' : 'Start'), + ), + ), + const SizedBox(width: 10), + IconButton.filledTonal( + tooltip: 'Reset timer', + onPressed: _reset, + icon: const Icon(Icons.restart_alt), + ), + ], + ), + const SizedBox(height: 8), + TextButton.icon( + onPressed: _completeNow, + icon: const Icon(Icons.check), + label: const Text('Mark session complete'), + ), + ], + ), + ), + const SizedBox(height: 16), + SeedCard( + child: ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.edit_note_outlined), + title: const Text('Finish with a one-minute reflection'), + subtitle: const Text('Capture what worked while it is fresh.'), + trailing: const Icon(Icons.arrow_forward_ios, size: 16), + onTap: widget.onReflect, + ), + ), + ], + ), + ); + } + + void _start() { + if (_running) { + return; + } + HapticFeedback.selectionClick(); + setState(() => _running = true); + _timer = Timer.periodic(const Duration(seconds: 1), (_) { + if (_remainingSeconds <= 1) { + _completeNow(); + return; + } + setState(() => _remainingSeconds -= 1); + }); + } + + void _pause() { + _timer?.cancel(); + setState(() => _running = false); + } + + void _reset() { + _timer?.cancel(); + setState(() { + _running = false; + _remainingSeconds = _selectedMinutes * 60; + }); + } + + void _completeNow() { + _timer?.cancel(); + final completedMinutes = _selectedMinutes; + setState(() { + _running = false; + _remainingSeconds = _selectedMinutes * 60; + }); + HapticFeedback.mediumImpact(); + widget.onComplete(completedMinutes); + showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Session saved'), + content: const Text( + 'Take one minute to write what helped you focus.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Later'), + ), + FilledButton( + onPressed: () { + Navigator.pop(context); + widget.onReflect(); + }, + child: const Text('Reflect'), + ), + ], + ), + ); + } +} diff --git a/lib/src/models/app_state.dart b/lib/src/models/app_state.dart new file mode 100644 index 0000000..43b89aa --- /dev/null +++ b/lib/src/models/app_state.dart @@ -0,0 +1,93 @@ +import 'focus_session.dart'; +import 'habit.dart'; +import 'reflection_entry.dart'; + +class AppState { + const AppState({ + required this.anchor, + required this.habits, + required this.sessions, + required this.reflections, + }); + + final String anchor; + final List habits; + final List sessions; + final List reflections; + + bool get isFreshStart => habits.isEmpty && sessions.isEmpty && anchor.isEmpty; + + AppState copyWith({ + String? anchor, + List? habits, + List? sessions, + List? reflections, + }) { + return AppState( + anchor: anchor ?? this.anchor, + habits: habits ?? this.habits, + sessions: sessions ?? this.sessions, + reflections: reflections ?? this.reflections, + ); + } + + Map toJson() { + return { + 'version': 1, + 'anchor': anchor, + 'habits': habits.map((habit) => habit.toJson()).toList(), + 'sessions': sessions.map((session) => session.toJson()).toList(), + 'reflections': + reflections.map((reflection) => reflection.toJson()).toList(), + }; + } + + static AppState fromJson(Map json) { + return AppState( + anchor: (json['anchor'] as String?) ?? '', + habits: _jsonObjects(json['habits']).map(Habit.fromJson).toList(), + sessions: + _jsonObjects(json['sessions']).map(FocusSession.fromJson).toList(), + reflections: + _jsonObjects( + json['reflections'], + ).map(ReflectionEntry.fromJson).toList(), + ); + } + + static Iterable> _jsonObjects(Object? value) { + if (value is! List) { + return const Iterable>.empty(); + } + return value.whereType>(); + } + + static AppState starter() { + final now = DateTime.now(); + return AppState( + anchor: '', + habits: [ + Habit( + id: 'habit-${now.microsecondsSinceEpoch}-1', + title: 'Plan one clear task', + createdAt: now, + completedDates: {}, + ), + Habit( + id: 'habit-${now.microsecondsSinceEpoch}-2', + title: 'Focus for 25 minutes', + createdAt: now, + completedDates: {}, + ), + Habit( + id: 'habit-${now.microsecondsSinceEpoch}-3', + title: 'Write a one-line reflection', + createdAt: now, + completedDates: {}, + ), + ], + sessions: const [], + reflections: const [], + ); + } +} diff --git a/lib/src/models/focus_session.dart b/lib/src/models/focus_session.dart new file mode 100644 index 0000000..b00fcd0 --- /dev/null +++ b/lib/src/models/focus_session.dart @@ -0,0 +1,27 @@ +class FocusSession { + const FocusSession({ + required this.id, + required this.minutes, + required this.completedAt, + }); + + final String id; + final int minutes; + final DateTime completedAt; + + Map toJson() { + return { + 'id': id, + 'minutes': minutes, + 'completedAt': completedAt.toIso8601String(), + }; + } + + static FocusSession fromJson(Map json) { + return FocusSession( + id: json['id'] as String, + minutes: json['minutes'] as int, + completedAt: DateTime.parse(json['completedAt'] as String), + ); + } +} diff --git a/lib/src/models/habit.dart b/lib/src/models/habit.dart new file mode 100644 index 0000000..592b984 --- /dev/null +++ b/lib/src/models/habit.dart @@ -0,0 +1,43 @@ +class Habit { + const Habit({ + required this.id, + required this.title, + required this.createdAt, + required this.completedDates, + }); + + final String id; + final String title; + final DateTime createdAt; + final Set completedDates; + + Habit copyWith({String? title, Set? completedDates}) { + return Habit( + id: id, + title: title ?? this.title, + createdAt: createdAt, + completedDates: completedDates ?? this.completedDates, + ); + } + + Map toJson() { + return { + 'id': id, + 'title': title, + 'createdAt': createdAt.toIso8601String(), + 'completedDates': completedDates.toList()..sort(), + }; + } + + static Habit fromJson(Map json) { + return Habit( + id: json['id'] as String, + title: json['title'] as String, + createdAt: DateTime.parse(json['createdAt'] as String), + completedDates: + ((json['completedDates'] as List?) ?? const []) + .map((value) => value.toString()) + .toSet(), + ); + } +} diff --git a/lib/src/models/mood.dart b/lib/src/models/mood.dart new file mode 100644 index 0000000..d7166b7 --- /dev/null +++ b/lib/src/models/mood.dart @@ -0,0 +1,13 @@ +import 'package:flutter/material.dart'; + +enum Mood { + calm('Calm', Icons.spa_outlined), + clear('Clear', Icons.wb_sunny_outlined), + proud('Proud', Icons.emoji_events_outlined), + tired('Tired', Icons.bedtime_outlined); + + const Mood(this.label, this.icon); + + final String label; + final IconData icon; +} diff --git a/lib/src/models/reflection_entry.dart b/lib/src/models/reflection_entry.dart new file mode 100644 index 0000000..cf2e84f --- /dev/null +++ b/lib/src/models/reflection_entry.dart @@ -0,0 +1,40 @@ +import 'mood.dart'; + +class ReflectionEntry { + const ReflectionEntry({ + required this.id, + required this.dateKey, + required this.note, + required this.mood, + required this.createdAt, + }); + + final String id; + final String dateKey; + final String note; + final Mood mood; + final DateTime createdAt; + + Map toJson() { + return { + 'id': id, + 'dateKey': dateKey, + 'note': note, + 'mood': mood.name, + 'createdAt': createdAt.toIso8601String(), + }; + } + + static ReflectionEntry fromJson(Map json) { + return ReflectionEntry( + id: json['id'] as String, + dateKey: json['dateKey'] as String, + note: json['note'] as String, + mood: Mood.values.firstWhere( + (mood) => mood.name == json['mood'], + orElse: () => Mood.clear, + ), + createdAt: DateTime.parse(json['createdAt'] as String), + ); + } +} diff --git a/lib/src/services/analytics_provider.dart b/lib/src/services/analytics_provider.dart new file mode 100644 index 0000000..b6ccd93 --- /dev/null +++ b/lib/src/services/analytics_provider.dart @@ -0,0 +1,10 @@ +abstract interface class AnalyticsProvider { + void track(String event, [Map properties = const {}]); +} + +class NoopAnalyticsProvider implements AnalyticsProvider { + const NoopAnalyticsProvider(); + + @override + void track(String event, [Map properties = const {}]) {} +} diff --git a/lib/src/services/monetization_provider.dart b/lib/src/services/monetization_provider.dart new file mode 100644 index 0000000..2d016ce --- /dev/null +++ b/lib/src/services/monetization_provider.dart @@ -0,0 +1,46 @@ +enum PremiumState { free, premium } + +enum AdPlacement { statsBottom, reflectionComplete } + +sealed class PurchaseResult { + const PurchaseResult(); + + static const unavailable = PurchaseUnavailable(); +} + +class PurchaseUnavailable extends PurchaseResult { + const PurchaseUnavailable(); +} + +abstract interface class MonetizationProvider { + bool get adsEnabled; + bool get billingEnabled; + Stream premiumState(); + Future showInterstitialIfEligible(AdPlacement placement); + Future startPurchase(String productId); + Future restorePurchases(); +} + +class NoopMonetizationProvider implements MonetizationProvider { + const NoopMonetizationProvider(); + + @override + bool get adsEnabled => false; + + @override + bool get billingEnabled => false; + + @override + Stream premiumState() => Stream.value(PremiumState.free); + + @override + Future showInterstitialIfEligible(AdPlacement placement) async {} + + @override + Future startPurchase(String productId) async { + return PurchaseResult.unavailable; + } + + @override + Future restorePurchases() async {} +} diff --git a/lib/src/widgets/app_scaffold.dart b/lib/src/widgets/app_scaffold.dart new file mode 100644 index 0000000..ede6f5d --- /dev/null +++ b/lib/src/widgets/app_scaffold.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; + +class AppScaffold extends StatelessWidget { + const AppScaffold({ + required this.title, + required this.subtitle, + required this.child, + this.action, + super.key, + }); + + final String title; + final String subtitle; + final Widget child; + final Widget? action; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 18, 20, 8), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context).textTheme.headlineSmall + ?.copyWith(fontWeight: FontWeight.w800), + ), + const SizedBox(height: 3), + Text( + subtitle, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + if (action != null) action!, + ], + ), + ), + Expanded(child: child), + ], + ); + } +} diff --git a/lib/src/widgets/dialogs.dart b/lib/src/widgets/dialogs.dart new file mode 100644 index 0000000..4ebd6e0 --- /dev/null +++ b/lib/src/widgets/dialogs.dart @@ -0,0 +1,217 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../core/json_utils.dart'; +import '../models/app_state.dart'; + +Future showHabitDialog( + BuildContext context, + ValueChanged onSubmit, { + String initialValue = '', +}) async { + final controller = TextEditingController(text: initialValue); + await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: Text(initialValue.isEmpty ? 'Add habit' : 'Rename habit'), + content: TextField( + controller: controller, + autofocus: true, + textCapitalization: TextCapitalization.sentences, + decoration: const InputDecoration( + border: OutlineInputBorder(), + hintText: 'Example: Read 10 pages', + ), + onSubmitted: (value) { + onSubmit(value); + Navigator.pop(context); + }, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + onSubmit(controller.text); + Navigator.pop(context); + }, + child: const Text('Save'), + ), + ], + ), + ); + controller.dispose(); +} + +Future confirmDeleteHabit( + BuildContext context, + VoidCallback onConfirm, +) async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Delete habit?'), + content: const Text( + 'Past completion records for this habit will be removed.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + onConfirm(); + Navigator.pop(context); + }, + child: const Text('Delete'), + ), + ], + ), + ); +} + +Future showExportDialog(BuildContext context, AppState state) async { + final controller = TextEditingController(text: prettyJson(state.toJson())); + await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Export JSON'), + content: SizedBox( + width: double.maxFinite, + child: TextField( + controller: controller, + minLines: 8, + maxLines: 12, + readOnly: true, + decoration: const InputDecoration(border: OutlineInputBorder()), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Close'), + ), + FilledButton.icon( + onPressed: () async { + await Clipboard.setData(ClipboardData(text: controller.text)); + if (context.mounted) { + Navigator.pop(context); + showSnack(context, 'Export copied'); + } + }, + icon: const Icon(Icons.copy), + label: const Text('Copy'), + ), + ], + ), + ); + controller.dispose(); +} + +Future showImportDialog( + BuildContext context, + ValueChanged onImport, +) async { + final controller = TextEditingController(); + await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Import JSON'), + content: TextField( + controller: controller, + minLines: 8, + maxLines: 12, + decoration: const InputDecoration( + border: OutlineInputBorder(), + hintText: 'Paste Focus Seed JSON export', + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + onImport(controller.text); + Navigator.pop(context); + }, + child: const Text('Import'), + ), + ], + ), + ); + controller.dispose(); +} + +Future confirmClearData( + BuildContext context, + VoidCallback onConfirm, +) async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Clear local data?'), + content: const Text( + 'This resets habits, focus sessions, and reflections on this device.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + onConfirm(); + Navigator.pop(context); + }, + child: const Text('Clear'), + ), + ], + ), + ); +} + +void showPrivacySheet(BuildContext context) { + showModalBottomSheet( + context: context, + showDragHandle: true, + builder: + (context) => Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 24), + child: ListView( + shrinkWrap: true, + children: const [ + Text( + 'Privacy', + style: TextStyle(fontSize: 22, fontWeight: FontWeight.w800), + ), + SizedBox(height: 12), + Text( + 'Focus Seed stores your anchor task, habits, focus sessions, ' + 'reflections, and settings locally on this device. V1 does not ' + 'include account sign-in, cloud sync, ads, analytics SDKs, or ' + 'billing SDKs.', + ), + SizedBox(height: 12), + Text( + 'You can export your local data as JSON, import a previous ' + 'export, or clear local data from Settings.', + ), + ], + ), + ), + ); +} + +void showSnack(BuildContext context, String message) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); +} diff --git a/lib/src/widgets/empty_state.dart b/lib/src/widgets/empty_state.dart new file mode 100644 index 0000000..6de6d5b --- /dev/null +++ b/lib/src/widgets/empty_state.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; + +import 'seed_card.dart'; + +class EmptyState extends StatelessWidget { + const EmptyState({ + required this.icon, + required this.title, + required this.action, + required this.onAction, + super.key, + }); + + final IconData icon; + final String title; + final String action; + final VoidCallback onAction; + + @override + Widget build(BuildContext context) { + return SeedCard( + child: Column( + children: [ + Icon(icon, size: 42, color: Theme.of(context).colorScheme.primary), + const SizedBox(height: 12), + Text(title, style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: onAction, + icon: const Icon(Icons.add), + label: Text(action), + ), + ], + ), + ); + } +} diff --git a/lib/src/widgets/habit_row.dart b/lib/src/widgets/habit_row.dart new file mode 100644 index 0000000..d78cd42 --- /dev/null +++ b/lib/src/widgets/habit_row.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; + +import '../models/habit.dart'; +import 'seed_card.dart'; + +class HabitRow extends StatelessWidget { + const HabitRow({ + required this.habit, + required this.dateKey, + required this.onToggle, + this.onEdit, + this.onDelete, + super.key, + }); + + final Habit habit; + final String dateKey; + final VoidCallback onToggle; + final VoidCallback? onEdit; + final VoidCallback? onDelete; + + @override + Widget build(BuildContext context) { + final done = habit.completedDates.contains(dateKey); + return AnimatedContainer( + duration: const Duration(milliseconds: 180), + margin: const EdgeInsets.only(bottom: 10), + child: SeedCard( + child: Row( + children: [ + IconButton.filledTonal( + tooltip: done ? 'Mark not done' : 'Mark done', + onPressed: onToggle, + icon: AnimatedSwitcher( + duration: const Duration(milliseconds: 160), + child: Icon( + done ? Icons.check : Icons.circle_outlined, + key: ValueKey(done), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + habit.title, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + decoration: done ? TextDecoration.lineThrough : null, + color: + done + ? Theme.of(context).colorScheme.onSurfaceVariant + : null, + ), + ), + ), + if (onEdit != null) + PopupMenuButton( + tooltip: 'Habit options', + onSelected: (value) { + if (value == 'edit') { + onEdit?.call(); + } + if (value == 'delete') { + onDelete?.call(); + } + }, + itemBuilder: + (context) => const [ + PopupMenuItem(value: 'edit', child: Text('Rename')), + PopupMenuItem(value: 'delete', child: Text('Delete')), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/widgets/inline_editable_text.dart b/lib/src/widgets/inline_editable_text.dart new file mode 100644 index 0000000..00b12dc --- /dev/null +++ b/lib/src/widgets/inline_editable_text.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; + +class InlineEditableText extends StatefulWidget { + const InlineEditableText({ + required this.value, + required this.emptyText, + required this.onSubmitted, + super.key, + }); + + final String value; + final String emptyText; + final ValueChanged onSubmitted; + + @override + State createState() => _InlineEditableTextState(); +} + +class _InlineEditableTextState extends State { + late final TextEditingController _controller; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.value); + } + + @override + void didUpdateWidget(covariant InlineEditableText oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.value != widget.value && _controller.text != widget.value) { + _controller.text = widget.value; + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return TextField( + controller: _controller, + textCapitalization: TextCapitalization.sentences, + onSubmitted: widget.onSubmitted, + onTapOutside: (_) => widget.onSubmitted(_controller.text), + decoration: InputDecoration( + hintText: widget.emptyText, + suffixIcon: IconButton( + tooltip: 'Save anchor', + onPressed: () => widget.onSubmitted(_controller.text), + icon: const Icon(Icons.check), + ), + border: const OutlineInputBorder(), + ), + ); + } +} diff --git a/lib/src/widgets/loading_view.dart b/lib/src/widgets/loading_view.dart new file mode 100644 index 0000000..61cae8d --- /dev/null +++ b/lib/src/widgets/loading_view.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +class LoadingView extends StatelessWidget { + const LoadingView({super.key}); + + @override + Widget build(BuildContext context) { + return const Center(child: CircularProgressIndicator()); + } +} diff --git a/lib/src/widgets/metric_tile.dart b/lib/src/widgets/metric_tile.dart new file mode 100644 index 0000000..7e6f97c --- /dev/null +++ b/lib/src/widgets/metric_tile.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; + +import 'seed_card.dart'; + +class MetricTile extends StatelessWidget { + const MetricTile({ + required this.label, + required this.value, + required this.suffix, + required this.icon, + this.accent, + super.key, + }); + + final String label; + final String value; + final String suffix; + final IconData icon; + final Color? accent; + + @override + Widget build(BuildContext context) { + final color = accent ?? Theme.of(context).colorScheme.primary; + return SeedCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, color: color), + const SizedBox(height: 12), + FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + value, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + if (suffix.isNotEmpty) ...[ + const SizedBox(width: 4), + Padding( + padding: const EdgeInsets.only(bottom: 3), + child: Text( + suffix, + style: Theme.of(context).textTheme.labelMedium, + ), + ), + ], + ], + ), + ), + const SizedBox(height: 4), + Text(label, style: Theme.of(context).textTheme.labelMedium), + ], + ), + ); + } +} diff --git a/lib/src/widgets/section_label.dart b/lib/src/widgets/section_label.dart new file mode 100644 index 0000000..3b1849e --- /dev/null +++ b/lib/src/widgets/section_label.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; + +class SectionLabel extends StatelessWidget { + const SectionLabel({required this.icon, required this.text, super.key}); + + final IconData icon; + final String text; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Icon(icon, size: 18, color: Theme.of(context).colorScheme.primary), + const SizedBox(width: 8), + Text( + text, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w700, + ), + ), + ], + ); + } +} diff --git a/lib/src/widgets/seed_card.dart b/lib/src/widgets/seed_card.dart new file mode 100644 index 0000000..dcd3b39 --- /dev/null +++ b/lib/src/widgets/seed_card.dart @@ -0,0 +1,16 @@ +import 'package:flutter/material.dart'; + +class SeedCard extends StatelessWidget { + const SeedCard({required this.child, this.margin, super.key}); + + final Widget child; + final EdgeInsetsGeometry? margin; + + @override + Widget build(BuildContext context) { + return Card( + margin: margin, + child: Padding(padding: const EdgeInsets.all(16), child: child), + ); + } +} diff --git a/lib/src/widgets/settings_tile.dart b/lib/src/widgets/settings_tile.dart new file mode 100644 index 0000000..b721114 --- /dev/null +++ b/lib/src/widgets/settings_tile.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +class SettingsTile extends StatelessWidget { + const SettingsTile({ + required this.icon, + required this.title, + required this.subtitle, + required this.onTap, + this.destructive = false, + super.key, + }); + + final IconData icon; + final String title; + final String subtitle; + final VoidCallback onTap; + final bool destructive; + + @override + Widget build(BuildContext context) { + final color = destructive ? Theme.of(context).colorScheme.error : null; + return ListTile( + contentPadding: EdgeInsets.zero, + leading: Icon(icon, color: color), + title: Text(title, style: TextStyle(color: color)), + subtitle: Text(subtitle), + trailing: const Icon(Icons.arrow_forward_ios, size: 16), + onTap: onTap, + ); + } +} diff --git a/lib/src/widgets/weekly_progress.dart b/lib/src/widgets/weekly_progress.dart new file mode 100644 index 0000000..c786ac0 --- /dev/null +++ b/lib/src/widgets/weekly_progress.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; + +import '../core/date_keys.dart'; +import '../models/app_state.dart'; +import 'section_label.dart'; +import 'seed_card.dart'; + +class WeeklyProgress extends StatelessWidget { + const WeeklyProgress({required this.state, super.key}); + + final AppState state; + + @override + Widget build(BuildContext context) { + final days = lastSevenDateKeys(); + return SeedCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SectionLabel( + icon: Icons.insights_outlined, + text: '7-day pulse', + ), + const SizedBox(height: 14), + Row( + children: + days.map((day) { + final minutes = minutesForDate(state.sessions, day); + final habits = + state.habits + .where((habit) => habit.completedDates.contains(day)) + .length; + final hasReflection = state.reflections.any( + (reflection) => reflection.dateKey == day, + ); + final score = + (minutes > 0 ? 1 : 0) + + (habits > 0 ? 1 : 0) + + (hasReflection ? 1 : 0); + return Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 3), + child: Column( + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 180), + height: 48 + (score * 8), + decoration: BoxDecoration( + color: + score == 0 + ? Theme.of( + context, + ).colorScheme.surfaceContainerHighest + : Theme.of( + context, + ).colorScheme.primary.withValues( + alpha: 0.35 + score * 0.15, + ), + borderRadius: BorderRadius.circular(8), + ), + ), + const SizedBox(height: 6), + Text( + day.substring(5), + style: Theme.of(context).textTheme.labelSmall, + ), + ], + ), + ), + ); + }).toList(), + ), + ], + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..fd253df --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,346 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.12.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.4.0" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.19.1" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.8" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.2" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.flutter-io.cn" + source: hosted + version: "7.0.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec + url: "https://pub.flutter-io.cn" + source: hosted + version: "10.0.8" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.9" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.1" + lints: + dependency: transitive + description: + name: lints + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.1.1" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.12.17" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.16.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.9.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.8" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.5.3" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: bd14436108211b0d4ee5038689a56d4ae3620fd72fd6036e113bf1345bc74d9e + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.13" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.5.4" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.flutter-io.cn" + 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: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.10.1" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.7.4" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.4" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14" + url: "https://pub.flutter-io.cn" + source: hosted + version: "14.3.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.0" +sdks: + dart: ">=3.7.2 <4.0.0" + flutter: ">=3.29.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..33c3aa9 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,90 @@ +name: focus_seed +description: "A private focus timer, habit tracker, and one-minute reflection app." +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: ^3.7.2 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + shared_preferences: ^2.5.3 + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^5.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..bf911a2 --- /dev/null +++ b/test/widget_test.dart @@ -0,0 +1,16 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:focus_seed/src/app/focus_seed_app.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + testWidgets('Focus Seed opens to the today screen', (tester) async { + SharedPreferences.setMockInitialValues({}); + + await tester.pumpWidget(const FocusSeedApp()); + await tester.pumpAndSettle(); + + expect(find.text('Focus Seed'), findsOneWidget); + expect(find.text('Today anchor'), findsOneWidget); + expect(find.text('Start focus'), findsOneWidget); + }); +}