Initial scaffold: FocusFlow ADHD Task Manager Flutter app

BLoC/Cubit state management, ADHD-friendly theme (calming teal, no red),
GetIt DI, GoRouter navigation. Screens: task dashboard, focus mode,
task create/detail, streaks, time perception, settings, onboarding, auth.
Custom widgets: TaskCard, RewardPopup, StreakRing, GentleNudgeCard.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Oracle Public Cloud User
2026-03-04 15:53:58 +00:00
commit 50931d839d
105 changed files with 7750 additions and 0 deletions

45
.gitignore vendored Normal file
View File

@@ -0,0 +1,45 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
# 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

36
.metadata Normal file
View File

@@ -0,0 +1,36 @@
# 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
- platform: web
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'

16
README.md Normal file
View File

@@ -0,0 +1,16 @@
# focusflow_app
A new Flutter project.
## Getting Started
This project is a starting point for a Flutter application.
A few resources to get you started if this is your first Flutter project:
- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.

28
analysis_options.yaml Normal file
View File

@@ -0,0 +1,28 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

14
android/.gitignore vendored Normal file
View File

@@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks

View File

@@ -0,0 +1,44 @@
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.focusflow.focusflow_app"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_11.toString()
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.focusflow.focusflow_app"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
}
flutter {
source = "../.."
}

View File

@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@@ -0,0 +1,45 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="focusflow_app"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>

View File

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

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

21
android/build.gradle.kts Normal file
View File

@@ -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<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}

View File

@@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
android.enableJetifier=true

View File

@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip

View File

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

View File

0
assets/images/.gitkeep Normal file
View File

34
ios/.gitignore vendored Normal file
View File

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

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>App</string>
<key>CFBundleIdentifier</key>
<string>io.flutter.flutter.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>App</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>12.0</string>
</dict>
</plist>

View File

@@ -0,0 +1 @@
#include "Generated.xcconfig"

View File

@@ -0,0 +1 @@
#include "Generated.xcconfig"

View File

@@ -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 = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
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 = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
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 = "<group>"; };
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
/* 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 = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
9740EEB31CF90195004384FC /* Generated.xcconfig */,
);
name = Flutter;
sourceTree = "<group>";
};
97C146E51CF9000F007C117D = {
isa = PBXGroup;
children = (
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
);
sourceTree = "<group>";
};
97C146EF1CF9000F007C117D /* Products */ = {
isa = PBXGroup;
children = (
97C146EE1CF9000F007C117D /* Runner.app */,
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
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 = "<group>";
};
/* 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 = "<group>";
};
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C147001CF9000F007C117D /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
/* 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.focusflow.focusflowApp;
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.focusflow.focusflowApp.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.focusflow.focusflowApp.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.focusflow.focusflowApp.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.focusflow.focusflowApp;
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.focusflow.focusflowApp;
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 */;
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>

View File

@@ -0,0 +1,99 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1510"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "331C8080294A63A400263BE5"
BuildableName = "RunnerTests.xctest"
BlueprintName = "RunnerTests"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Profile"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
</Workspace>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

View File

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

View File

@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
</resources>
</document>

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
</dependencies>
<scenes>
<!--Flutter View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>

49
ios/Runner/Info.plist Normal file
View File

@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Focusflow App</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>focusflow_app</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1 @@
#import "GeneratedPluginRegistrant.h"

View File

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

31
lib/app.dart Normal file
View File

@@ -0,0 +1,31 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'core/theme/app_theme.dart';
import 'features/auth/presentation/bloc/auth_bloc.dart';
import 'routing/app_router.dart';
/// Root widget for the FocusFlow app.
///
/// Provides BLoCs at the top of the tree so they are available to every
/// route, then delegates to GoRouter for navigation.
class FocusFlowApp extends StatelessWidget {
const FocusFlowApp({super.key});
@override
Widget build(BuildContext context) {
return MultiBlocProvider(
providers: [
BlocProvider<AuthBloc>(create: (_) => AuthBloc()..add(AuthCheckRequested())),
],
child: MaterialApp.router(
title: 'FocusFlow',
debugShowCheckedModeBanner: false,
theme: AppTheme.light,
darkTheme: AppTheme.dark,
themeMode: ThemeMode.system,
routerConfig: AppRouter.router,
),
);
}
}

View File

@@ -0,0 +1,45 @@
import 'package:equatable/equatable.dart';
/// Base failure type for the application.
///
/// Uses a sealed class hierarchy so `switch` expressions are exhaustive.
sealed class Failure extends Equatable {
final String message;
const Failure(this.message);
@override
List<Object?> get props => [message];
}
/// Returned when the backend API responds with a non-2xx status.
class ServerFailure extends Failure {
final int? statusCode;
const ServerFailure(super.message, {this.statusCode});
@override
List<Object?> get props => [message, statusCode];
}
/// Returned when the device has no network connectivity.
class NetworkFailure extends Failure {
const NetworkFailure([super.message = 'No internet connection. Your changes are saved locally.']);
}
/// Returned when reading / writing the local cache fails.
class CacheFailure extends Failure {
const CacheFailure([super.message = 'Could not access local storage.']);
}
/// Returned for authentication problems (expired token, bad credentials, etc.).
class AuthFailure extends Failure {
const AuthFailure([super.message = 'Authentication failed. Please sign in again.']);
}
/// Returned when user input does not pass validation.
class ValidationFailure extends Failure {
final Map<String, String> fieldErrors;
const ValidationFailure(super.message, {this.fieldErrors = const {}});
@override
List<Object?> get props => [message, fieldErrors];
}

View File

@@ -0,0 +1,153 @@
import 'package:dio/dio.dart';
import 'package:focusflow_shared/focusflow_shared.dart';
import 'interceptors/auth_interceptor.dart';
/// Central Dio wrapper for all API communication.
///
/// Registered as a lazy singleton in GetIt so every feature shares
/// the same HTTP client (and therefore the same auth interceptor,
/// retry logic, and error normalisation).
class ApiClient {
late final Dio _dio;
ApiClient() {
_dio = Dio(
BaseOptions(
// TODO: read from env / config
baseUrl: 'https://api.focusflow.app',
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 15),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
),
);
_dio.interceptors.addAll([
AuthInterceptor(),
LogInterceptor(requestBody: true, responseBody: true),
]);
}
// ── Convenience HTTP verbs ───────────────────────────────────────
Future<Response<T>> get<T>(
String path, {
Map<String, dynamic>? queryParameters,
Options? options,
}) =>
_dio.get<T>(path, queryParameters: queryParameters, options: options);
Future<Response<T>> post<T>(
String path, {
Object? data,
Options? options,
}) =>
_dio.post<T>(path, data: data, options: options);
Future<Response<T>> put<T>(
String path, {
Object? data,
Options? options,
}) =>
_dio.put<T>(path, data: data, options: options);
Future<Response<T>> patch<T>(
String path, {
Object? data,
Options? options,
}) =>
_dio.patch<T>(path, data: data, options: options);
Future<Response<T>> delete<T>(
String path, {
Object? data,
Options? options,
}) =>
_dio.delete<T>(path, data: data, options: options);
// ── Auth helpers ─────────────────────────────────────────────────
Future<ApiResponse<Map<String, dynamic>>> login(String email, String password) async {
final response = await post<Map<String, dynamic>>(
ApiConstants.authLogin,
data: {'email': email, 'password': password},
);
return ApiResponse.fromJson(
response.data!,
(json) => json as Map<String, dynamic>,
);
}
Future<ApiResponse<Map<String, dynamic>>> register(
String displayName,
String email,
String password,
) async {
final response = await post<Map<String, dynamic>>(
ApiConstants.authRegister,
data: {
'displayName': displayName,
'email': email,
'password': password,
},
);
return ApiResponse.fromJson(
response.data!,
(json) => json as Map<String, dynamic>,
);
}
Future<void> logout() => post(ApiConstants.authLogout);
// ── Task helpers ─────────────────────────────────────────────────
Future<Response<Map<String, dynamic>>> fetchTasks({
int page = 1,
int limit = 20,
}) =>
get<Map<String, dynamic>>(
ApiConstants.tasks,
queryParameters: {'page': page, 'limit': limit},
);
Future<Response<Map<String, dynamic>>> fetchNextTask() =>
get<Map<String, dynamic>>(ApiConstants.nextTask);
Future<Response<Map<String, dynamic>>> completeTask(String id) =>
post<Map<String, dynamic>>(ApiConstants.completeTask(id));
Future<Response<Map<String, dynamic>>> skipTask(String id) =>
post<Map<String, dynamic>>(ApiConstants.skipTask(id));
Future<Response<Map<String, dynamic>>> createTask(Map<String, dynamic> data) =>
post<Map<String, dynamic>>(ApiConstants.tasks, data: data);
Future<Response<Map<String, dynamic>>> deleteTask(String id) =>
delete<Map<String, dynamic>>(ApiConstants.task(id));
// ── Streak helpers ───────────────────────────────────────────────
Future<Response<Map<String, dynamic>>> fetchStreaks() =>
get<Map<String, dynamic>>(ApiConstants.streaks);
Future<Response<Map<String, dynamic>>> completeStreak(String id) =>
post<Map<String, dynamic>>(ApiConstants.streak(id));
Future<Response<Map<String, dynamic>>> forgiveStreak(String id) =>
post<Map<String, dynamic>>(ApiConstants.forgiveStreak(id));
// ── Reward helpers ───────────────────────────────────────────────
Future<Response<Map<String, dynamic>>> generateReward(String taskId) =>
post<Map<String, dynamic>>(
ApiConstants.generateReward,
data: {'taskId': taskId},
);
// ── Time helpers ─────────────────────────────────────────────────
Future<Response<Map<String, dynamic>>> fetchTimeAccuracy() =>
get<Map<String, dynamic>>(ApiConstants.timeAccuracy);
}

View File

@@ -0,0 +1,89 @@
import 'package:dio/dio.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:focusflow_shared/focusflow_shared.dart';
/// Dio interceptor that attaches a JWT Bearer token to every request
/// and transparently refreshes the token on 401 responses.
class AuthInterceptor extends Interceptor {
static const _storage = FlutterSecureStorage();
static const _accessTokenKey = 'ff_access_token';
static const _refreshTokenKey = 'ff_refresh_token';
// ── Attach token on every request ────────────────────────────────
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
final token = await _storage.read(key: _accessTokenKey);
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
}
// ── Handle 401 — attempt token refresh ───────────────────────────
@override
void onError(DioException err, ErrorInterceptorHandler handler) async {
if (err.response?.statusCode == 401) {
final refreshed = await _tryRefresh();
if (refreshed) {
// Retry the original request with the new token.
final token = await _storage.read(key: _accessTokenKey);
final opts = err.requestOptions;
opts.headers['Authorization'] = 'Bearer $token';
try {
final response = await Dio().fetch(opts);
return handler.resolve(response);
} on DioException catch (e) {
return handler.next(e);
}
}
}
handler.next(err);
}
// ── Token refresh ────────────────────────────────────────────────
Future<bool> _tryRefresh() async {
final refreshToken = await _storage.read(key: _refreshTokenKey);
if (refreshToken == null) return false;
try {
final dio = Dio();
final response = await dio.post<Map<String, dynamic>>(
// Use the same base url; in production this is configured once.
'https://api.focusflow.app${ApiConstants.authRefresh}',
data: {'refreshToken': refreshToken},
);
final data = response.data;
if (data != null && data['status'] == 'success') {
final tokens = data['data'] as Map<String, dynamic>;
await _storage.write(key: _accessTokenKey, value: tokens['accessToken'] as String);
await _storage.write(key: _refreshTokenKey, value: tokens['refreshToken'] as String);
return true;
}
} catch (_) {
// Refresh failed — caller will propagate the original 401.
}
return false;
}
// ── Static helpers for login / logout ────────────────────────────
static Future<void> saveTokens({
required String accessToken,
required String refreshToken,
}) async {
await _storage.write(key: _accessTokenKey, value: accessToken);
await _storage.write(key: _refreshTokenKey, value: refreshToken);
}
static Future<void> clearTokens() async {
await _storage.delete(key: _accessTokenKey);
await _storage.delete(key: _refreshTokenKey);
}
static Future<bool> hasToken() async {
final token = await _storage.read(key: _accessTokenKey);
return token != null;
}
}

View File

@@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
/// ADHD-friendly color palette.
///
/// Designed around calming, non-aggressive tones that reduce
/// visual anxiety and avoid triggering shame or urgency.
class AppColors {
AppColors._();
// ── Primary palette ──────────────────────────────────────────────
/// Calming teal — the anchor of the app.
static const Color primary = Color(0xFF26A69A);
static const Color primaryLight = Color(0xFF80CBC4);
static const Color primaryDark = Color(0xFF00897B);
// ── Secondary — warmth & rewards ─────────────────────────────────
/// Warm amber for positive reinforcement, rewards, celebrations.
static const Color secondary = Color(0xFFFFB74D);
static const Color secondaryLight = Color(0xFFFFE0B2);
static const Color secondaryDark = Color(0xFFF9A825);
// ── Tertiary — streaks & progress ────────────────────────────────
/// Soft purple for streak visuals and progress indicators.
static const Color tertiary = Color(0xFFAB47BC);
static const Color tertiaryLight = Color(0xFFCE93D8);
static const Color tertiaryDark = Color(0xFF8E24AA);
// ── Backgrounds ──────────────────────────────────────────────────
static const Color backgroundLight = Color(0xFFFAFAFA);
static const Color backgroundDark = Color(0xFF121212);
// ── Surfaces ─────────────────────────────────────────────────────
static const Color surfaceLight = Color(0xFFFFFFFF);
static const Color surfaceDark = Color(0xFF1E1E1E);
static const Color surfaceVariantLight = Color(0xFFF5F5F5);
static const Color surfaceVariantDark = Color(0xFF2C2C2C);
// ── Semantic — intentionally forgiving ───────────────────────────
/// Tasks completed — celebrate!
static const Color completed = Color(0xFF66BB6A);
/// Tasks skipped — neutral gray, no shame.
static const Color skipped = Color(0xFFBDBDBD);
/// Tasks missed — light gray, forgiveness over punishment.
static const Color missed = Color(0xFFE0E0E0);
/// Error — muted coral, not aggressive red.
static const Color error = Color(0xFFE57373);
static const Color errorLight = Color(0xFFFFCDD2);
// ── Reward gold ──────────────────────────────────────────────────
static const Color rewardGold = Color(0xFFFFD54F);
static const Color rewardGoldDark = Color(0xFFFFC107);
// ── Energy levels ────────────────────────────────────────────────
static const Color energyLow = Color(0xFF81C784);
static const Color energyMedium = Color(0xFFFFD54F);
static const Color energyHigh = Color(0xFFFF8A65);
// ── Text ─────────────────────────────────────────────────────────
static const Color textPrimaryLight = Color(0xFF212121);
static const Color textSecondaryLight = Color(0xFF616161);
static const Color textPrimaryDark = Color(0xFFFAFAFA);
static const Color textSecondaryDark = Color(0xFFBDBDBD);
// ── Misc ─────────────────────────────────────────────────────────
static const Color dividerLight = Color(0xFFE0E0E0);
static const Color dividerDark = Color(0xFF424242);
static const Color shimmerBase = Color(0xFFE0E0E0);
static const Color shimmerHighlight = Color(0xFFF5F5F5);
}

View File

@@ -0,0 +1,354 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'app_colors.dart';
/// ADHD-friendly Material 3 theme.
///
/// Design principles:
/// - Calming primary colors (teal / soft blue) — NO aggressive reds.
/// - High-contrast text for readability.
/// - Large touch targets (minimum 48 dp).
/// - Rounded corners everywhere (16 dp radius).
/// - Gentle animations, no jarring transitions.
/// - Nunito for headers (friendly, rounded), Inter for body.
/// - Subtle, soft card shadows.
class AppTheme {
AppTheme._();
static const double _borderRadius = 16.0;
static final _roundedShape = RoundedRectangleBorder(
borderRadius: BorderRadius.circular(_borderRadius),
);
// ── Typography ───────────────────────────────────────────────────
static TextTheme _buildTextTheme(TextTheme base, Color primary, Color secondary) {
final headlineFont = GoogleFonts.nunitoTextTheme(base);
final bodyFont = GoogleFonts.interTextTheme(base);
return bodyFont.copyWith(
displayLarge: headlineFont.displayLarge?.copyWith(color: primary, fontWeight: FontWeight.w700),
displayMedium: headlineFont.displayMedium?.copyWith(color: primary, fontWeight: FontWeight.w700),
displaySmall: headlineFont.displaySmall?.copyWith(color: primary, fontWeight: FontWeight.w700),
headlineLarge: headlineFont.headlineLarge?.copyWith(color: primary, fontWeight: FontWeight.w700),
headlineMedium: headlineFont.headlineMedium?.copyWith(color: primary, fontWeight: FontWeight.w600),
headlineSmall: headlineFont.headlineSmall?.copyWith(color: primary, fontWeight: FontWeight.w600),
titleLarge: headlineFont.titleLarge?.copyWith(color: primary, fontWeight: FontWeight.w600),
titleMedium: headlineFont.titleMedium?.copyWith(color: primary, fontWeight: FontWeight.w600),
titleSmall: headlineFont.titleSmall?.copyWith(color: primary, fontWeight: FontWeight.w500),
bodyLarge: bodyFont.bodyLarge?.copyWith(color: primary, fontSize: 16),
bodyMedium: bodyFont.bodyMedium?.copyWith(color: secondary, fontSize: 14),
bodySmall: bodyFont.bodySmall?.copyWith(color: secondary, fontSize: 12),
labelLarge: bodyFont.labelLarge?.copyWith(color: primary, fontWeight: FontWeight.w600, fontSize: 16),
labelMedium: bodyFont.labelMedium?.copyWith(color: secondary),
labelSmall: bodyFont.labelSmall?.copyWith(color: secondary),
);
}
// ── Light theme ──────────────────────────────────────────────────
static ThemeData get light {
final colorScheme = ColorScheme.light(
primary: AppColors.primary,
onPrimary: Colors.white,
primaryContainer: AppColors.primaryLight,
onPrimaryContainer: AppColors.primaryDark,
secondary: AppColors.secondary,
onSecondary: Colors.white,
secondaryContainer: AppColors.secondaryLight,
onSecondaryContainer: AppColors.secondaryDark,
tertiary: AppColors.tertiary,
onTertiary: Colors.white,
tertiaryContainer: AppColors.tertiaryLight,
onTertiaryContainer: AppColors.tertiaryDark,
surface: AppColors.surfaceLight,
onSurface: AppColors.textPrimaryLight,
surfaceContainerHighest: AppColors.surfaceVariantLight,
onSurfaceVariant: AppColors.textSecondaryLight,
error: AppColors.error,
onError: Colors.white,
);
final textTheme = _buildTextTheme(
ThemeData.light().textTheme,
AppColors.textPrimaryLight,
AppColors.textSecondaryLight,
);
return ThemeData(
useMaterial3: true,
colorScheme: colorScheme,
scaffoldBackgroundColor: AppColors.backgroundLight,
textTheme: textTheme,
// AppBar
appBarTheme: AppBarTheme(
elevation: 0,
scrolledUnderElevation: 1,
backgroundColor: AppColors.backgroundLight,
foregroundColor: AppColors.textPrimaryLight,
titleTextStyle: textTheme.titleLarge,
),
// Cards — subtle, soft shadows
cardTheme: CardThemeData(
elevation: 2,
shadowColor: Colors.black.withAlpha(25),
shape: _roundedShape,
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
),
// Elevated buttons — large touch targets
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
minimumSize: const Size(double.infinity, 52),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(_borderRadius)),
textStyle: textTheme.labelLarge,
elevation: 2,
shadowColor: Colors.black.withAlpha(25),
),
),
// Filled buttons
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
minimumSize: const Size(double.infinity, 52),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(_borderRadius)),
textStyle: textTheme.labelLarge,
),
),
// Outlined buttons
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
minimumSize: const Size(double.infinity, 52),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(_borderRadius)),
textStyle: textTheme.labelLarge,
),
),
// Text buttons
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
minimumSize: const Size(48, 48),
textStyle: textTheme.labelLarge,
),
),
// Input decoration
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: AppColors.surfaceVariantLight,
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 18),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(_borderRadius),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(_borderRadius),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(_borderRadius),
borderSide: const BorderSide(color: AppColors.primary, width: 2),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(_borderRadius),
borderSide: const BorderSide(color: AppColors.error, width: 1),
),
labelStyle: textTheme.bodyMedium,
hintStyle: textTheme.bodyMedium?.copyWith(color: AppColors.textSecondaryLight.withAlpha(150)),
),
// FABs
floatingActionButtonTheme: FloatingActionButtonThemeData(
backgroundColor: AppColors.primary,
foregroundColor: Colors.white,
elevation: 4,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
),
// Chips
chipTheme: ChipThemeData(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
),
// Bottom nav
bottomNavigationBarTheme: BottomNavigationBarThemeData(
elevation: 8,
selectedItemColor: AppColors.primary,
unselectedItemColor: AppColors.textSecondaryLight,
type: BottomNavigationBarType.fixed,
backgroundColor: AppColors.surfaceLight,
),
// Snackbar
snackBarTheme: SnackBarThemeData(
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
// Divider
dividerTheme: const DividerThemeData(
color: AppColors.dividerLight,
thickness: 1,
space: 1,
),
// Page transitions — gentle
pageTransitionsTheme: const PageTransitionsTheme(
builders: {
TargetPlatform.android: FadeUpwardsPageTransitionsBuilder(),
TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
},
),
);
}
// ── Dark theme — true dark with muted accents ────────────────────
static ThemeData get dark {
final colorScheme = ColorScheme.dark(
primary: AppColors.primaryLight,
onPrimary: AppColors.primaryDark,
primaryContainer: AppColors.primaryDark,
onPrimaryContainer: AppColors.primaryLight,
secondary: AppColors.secondary,
onSecondary: Colors.black,
secondaryContainer: AppColors.secondaryDark,
onSecondaryContainer: AppColors.secondaryLight,
tertiary: AppColors.tertiaryLight,
onTertiary: Colors.black,
tertiaryContainer: AppColors.tertiaryDark,
onTertiaryContainer: AppColors.tertiaryLight,
surface: AppColors.surfaceDark,
onSurface: AppColors.textPrimaryDark,
surfaceContainerHighest: AppColors.surfaceVariantDark,
onSurfaceVariant: AppColors.textSecondaryDark,
error: AppColors.errorLight,
onError: Colors.black,
);
final textTheme = _buildTextTheme(
ThemeData.dark().textTheme,
AppColors.textPrimaryDark,
AppColors.textSecondaryDark,
);
return ThemeData(
useMaterial3: true,
colorScheme: colorScheme,
scaffoldBackgroundColor: AppColors.backgroundDark,
textTheme: textTheme,
appBarTheme: AppBarTheme(
elevation: 0,
scrolledUnderElevation: 1,
backgroundColor: AppColors.backgroundDark,
foregroundColor: AppColors.textPrimaryDark,
titleTextStyle: textTheme.titleLarge,
),
cardTheme: CardThemeData(
elevation: 2,
shadowColor: Colors.black.withAlpha(60),
shape: _roundedShape,
color: AppColors.surfaceDark,
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
minimumSize: const Size(double.infinity, 52),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(_borderRadius)),
textStyle: textTheme.labelLarge,
elevation: 2,
shadowColor: Colors.black.withAlpha(60),
),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
minimumSize: const Size(double.infinity, 52),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(_borderRadius)),
textStyle: textTheme.labelLarge,
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
minimumSize: const Size(double.infinity, 52),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(_borderRadius)),
textStyle: textTheme.labelLarge,
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
minimumSize: const Size(48, 48),
textStyle: textTheme.labelLarge,
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: AppColors.surfaceVariantDark,
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 18),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(_borderRadius),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(_borderRadius),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(_borderRadius),
borderSide: const BorderSide(color: AppColors.primaryLight, width: 2),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(_borderRadius),
borderSide: const BorderSide(color: AppColors.errorLight, width: 1),
),
labelStyle: textTheme.bodyMedium,
hintStyle: textTheme.bodyMedium?.copyWith(color: AppColors.textSecondaryDark.withAlpha(150)),
),
floatingActionButtonTheme: FloatingActionButtonThemeData(
backgroundColor: AppColors.primaryLight,
foregroundColor: AppColors.primaryDark,
elevation: 4,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
),
chipTheme: ChipThemeData(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
),
bottomNavigationBarTheme: BottomNavigationBarThemeData(
elevation: 8,
selectedItemColor: AppColors.primaryLight,
unselectedItemColor: AppColors.textSecondaryDark,
type: BottomNavigationBarType.fixed,
backgroundColor: AppColors.surfaceDark,
),
snackBarTheme: SnackBarThemeData(
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
dividerTheme: const DividerThemeData(
color: AppColors.dividerDark,
thickness: 1,
space: 1,
),
pageTransitionsTheme: const PageTransitionsTheme(
builders: {
TargetPlatform.android: FadeUpwardsPageTransitionsBuilder(),
TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
},
),
);
}
}

View File

@@ -0,0 +1,96 @@
import 'package:flutter/material.dart';
import '../theme/app_colors.dart';
/// Re-engagement card shown when the user returns after an absence.
///
/// Design rules:
/// - Warm, welcoming message — "Welcome back! Your tasks missed you."
/// - Show previous streak info gently.
/// - "Let's start small" CTA.
/// - NO guilt, NO "you missed X days" in red.
class GentleNudgeCard extends StatelessWidget {
final int? previousStreak;
final VoidCallback? onStartSmall;
final VoidCallback? onDismiss;
const GentleNudgeCard({
super.key,
this.previousStreak,
this.onStartSmall,
this.onDismiss,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
color: AppColors.primaryLight.withAlpha(40),
elevation: 0,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── Header row ─────────────────────────────────────────
Row(
children: [
const Icon(Icons.waving_hand_rounded,
size: 28, color: AppColors.secondary),
const SizedBox(width: 10),
Expanded(
child: Text(
'Welcome back!',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
if (onDismiss != null)
IconButton(
icon: const Icon(Icons.close_rounded, size: 20),
onPressed: onDismiss,
style: IconButton.styleFrom(
minimumSize: const Size(36, 36),
),
),
],
),
const SizedBox(height: 10),
// ── Body ───────────────────────────────────────────────
Text(
'Your tasks missed you. No pressure — let\'s ease back in.',
style: theme.textTheme.bodyLarge,
),
if (previousStreak != null && previousStreak! > 0) ...[
const SizedBox(height: 8),
Text(
'You had a $previousStreak-day streak going. '
'That\'s still impressive — let\'s build on it.',
style: theme.textTheme.bodyMedium,
),
],
const SizedBox(height: 16),
// ── CTA ────────────────────────────────────────────────
FilledButton.icon(
onPressed: onStartSmall,
icon: const Icon(Icons.play_arrow_rounded),
label: const Text('Let\'s start small'),
style: FilledButton.styleFrom(
backgroundColor: AppColors.primary,
minimumSize: const Size(double.infinity, 52),
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,131 @@
import 'package:flutter/material.dart';
import 'package:focusflow_shared/focusflow_shared.dart';
import '../theme/app_colors.dart';
/// Reward celebration overlay.
///
/// Shows after a task is completed:
/// - Placeholder area for a Lottie animation.
/// - Points / magnitude display.
/// - Encouraging message.
/// - "Nice!" dismiss button.
/// - Surprise rewards get extra fanfare (larger, longer animation).
class RewardPopup extends StatelessWidget {
final Reward reward;
final VoidCallback onDismiss;
const RewardPopup({
super.key,
required this.reward,
required this.onDismiss,
});
/// Messages shown at random — always positive.
static const _messages = [
'You did it!',
'Awesome work!',
'One more down!',
'Keep that momentum!',
'Look at you go!',
'Your brain thanks you!',
'Small wins add up!',
'That felt good, right?',
];
String get _message {
if (reward.description != null && reward.description!.isNotEmpty) {
return reward.description!;
}
// Deterministic pick based on reward id hashCode so the same reward
// always shows the same message during a session.
return _messages[reward.id.hashCode.abs() % _messages.length];
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isSurprise = reward.isSurprise;
final size = isSurprise ? 140.0 : 100.0;
return Center(
child: Material(
color: Colors.transparent,
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 32),
padding: const EdgeInsets.all(28),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: Colors.black.withAlpha(40),
blurRadius: 24,
offset: const Offset(0, 8),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// ── Animation placeholder ────────────────────────────
Container(
width: size,
height: size,
decoration: BoxDecoration(
color: AppColors.rewardGold.withAlpha(40),
shape: BoxShape.circle,
),
child: Icon(
isSurprise ? Icons.auto_awesome : Icons.celebration_rounded,
size: size * 0.5,
color: AppColors.rewardGold,
),
),
const SizedBox(height: 20),
// ── Title ────────────────────────────────────────────
if (reward.title != null)
Text(
reward.title!,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w700,
),
textAlign: TextAlign.center,
),
if (reward.title != null) const SizedBox(height: 8),
// ── Points ───────────────────────────────────────────
Text(
'+${reward.magnitude} pts',
style: theme.textTheme.titleLarge?.copyWith(
color: AppColors.rewardGoldDark,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 12),
// ── Message ──────────────────────────────────────────
Text(
_message,
style: theme.textTheme.bodyLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
// ── Dismiss ──────────────────────────────────────────
FilledButton(
onPressed: onDismiss,
style: FilledButton.styleFrom(
backgroundColor: AppColors.primary,
minimumSize: const Size(160, 52),
),
child: const Text('Nice!'),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,179 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../theme/app_colors.dart';
/// Circular streak progress indicator.
///
/// - Ring fills based on current count vs a goal.
/// - Grace days shown as dashed segments.
/// - Frozen indicator (snowflake icon).
/// - Center shows current count.
/// - Warm teal/green when active, gray when in grace period.
class StreakRing extends StatelessWidget {
/// Current streak count.
final int currentCount;
/// Number of grace days remaining (max grace days minus used).
final int graceDaysRemaining;
/// Total grace days allowed.
final int totalGraceDays;
/// Whether the streak is currently frozen.
final bool isFrozen;
/// Size of the widget (width & height).
final double size;
/// Optional label beneath the count.
final String? label;
const StreakRing({
super.key,
required this.currentCount,
this.graceDaysRemaining = 0,
this.totalGraceDays = 2,
this.isFrozen = false,
this.size = 100,
this.label,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SizedBox(
width: size,
height: size,
child: CustomPaint(
painter: _StreakRingPainter(
progress: _progress,
graceProgress: _graceProgress,
isFrozen: isFrozen,
activeColor: AppColors.primary,
graceColor: AppColors.skipped,
frozenColor: AppColors.primaryLight,
backgroundColor: theme.dividerColor.withAlpha(50),
),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (isFrozen)
Icon(Icons.ac_unit_rounded,
size: size * 0.22, color: AppColors.primaryLight)
else
Text(
'$currentCount',
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w800,
fontSize: size * 0.26,
),
),
if (label != null)
Text(
label!,
style: theme.textTheme.bodySmall?.copyWith(fontSize: size * 0.1),
textAlign: TextAlign.center,
),
],
),
),
),
);
}
/// Normalised 0-1 progress for the main ring.
/// We cap visual progress at 1.0 but the count can exceed 30.
double get _progress => (currentCount / 30).clamp(0.0, 1.0);
/// Grace segment progress (shown as dashed portion after the main ring).
double get _graceProgress {
if (totalGraceDays == 0) return 0;
return ((totalGraceDays - graceDaysRemaining) / totalGraceDays).clamp(0.0, 1.0);
}
}
class _StreakRingPainter extends CustomPainter {
final double progress;
final double graceProgress;
final bool isFrozen;
final Color activeColor;
final Color graceColor;
final Color frozenColor;
final Color backgroundColor;
_StreakRingPainter({
required this.progress,
required this.graceProgress,
required this.isFrozen,
required this.activeColor,
required this.graceColor,
required this.frozenColor,
required this.backgroundColor,
});
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = (size.shortestSide / 2) - 6;
const strokeWidth = 8.0;
const startAngle = -math.pi / 2;
// Background ring
canvas.drawCircle(
center,
radius,
Paint()
..color = backgroundColor
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.round,
);
// Main progress arc
final sweepAngle = 2 * math.pi * progress;
if (progress > 0) {
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
startAngle,
sweepAngle,
false,
Paint()
..color = isFrozen ? frozenColor : activeColor
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.round,
);
}
// Grace dashed segments (small arcs after the progress)
if (graceProgress > 0 && !isFrozen) {
const graceArcLength = math.pi / 8; // each dash length
final graceStart = startAngle + sweepAngle + 0.05;
final dashCount = (graceProgress * 3).ceil().clamp(0, 3);
for (int i = 0; i < dashCount; i++) {
final dashStart = graceStart + i * (graceArcLength + 0.06);
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
dashStart,
graceArcLength,
false,
Paint()
..color = graceColor
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth * 0.6
..strokeCap = StrokeCap.round,
);
}
}
}
@override
bool shouldRepaint(covariant _StreakRingPainter oldDelegate) =>
progress != oldDelegate.progress ||
graceProgress != oldDelegate.graceProgress ||
isFrozen != oldDelegate.isFrozen;
}

View File

@@ -0,0 +1,215 @@
import 'package:flutter/material.dart';
import 'package:focusflow_shared/focusflow_shared.dart';
import '../theme/app_colors.dart';
/// ADHD-friendly task card.
///
/// Design principles:
/// - Large title text for scannability.
/// - Energy level indicator (colored dot).
/// - Estimated time badge.
/// - Tags as small chips.
/// - Gentle color coding — NO overwhelming information density.
/// - Tap to open, long-press for quick actions.
class TaskCard extends StatelessWidget {
final Task task;
final VoidCallback? onTap;
final VoidCallback? onComplete;
final VoidCallback? onSkip;
const TaskCard({
super.key,
required this.task,
this.onTap,
this.onComplete,
this.onSkip,
});
Color _energyColor() {
switch (task.energyLevel) {
case 'low':
return AppColors.energyLow;
case 'high':
return AppColors.energyHigh;
default:
return AppColors.energyMedium;
}
}
String _energyLabel() {
switch (task.energyLevel) {
case 'low':
return 'Low';
case 'high':
return 'High';
default:
return 'Med';
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: onTap,
onLongPress: () => _showQuickActions(context),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── Top row: title + energy dot ─────────────────────
Row(
children: [
// Energy dot
Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: _energyColor(),
shape: BoxShape.circle,
),
),
const SizedBox(width: 10),
// Title
Expanded(
child: Text(
task.title,
style: theme.textTheme.titleMedium?.copyWith(
fontSize: 18,
fontWeight: FontWeight.w600,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
if (task.description != null && task.description!.isNotEmpty) ...[
const SizedBox(height: 6),
Text(
task.description!,
style: theme.textTheme.bodyMedium,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
const SizedBox(height: 12),
// ── Bottom row: badges & tags ──────────────────────
Wrap(
spacing: 8,
runSpacing: 6,
children: [
// Energy badge
_Badge(
icon: Icons.bolt_rounded,
label: _energyLabel(),
color: _energyColor(),
),
// Estimated time
if (task.estimatedMinutes != null)
_Badge(
icon: Icons.timer_outlined,
label: '${task.estimatedMinutes} min',
color: AppColors.primary,
),
// Category
if (task.category != null)
_Badge(
icon: Icons.label_outline,
label: task.category!,
color: AppColors.tertiary,
),
// Tags
...task.tags.take(3).map((tag) => Chip(
label: Text(tag, style: const TextStyle(fontSize: 11)),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
)),
],
),
],
),
),
),
);
}
void _showQuickActions(BuildContext context) {
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (ctx) => SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.check_circle_outline, color: AppColors.completed),
title: const Text('Mark as done'),
onTap: () {
Navigator.pop(ctx);
onComplete?.call();
},
),
ListTile(
leading: const Icon(Icons.skip_next_outlined, color: AppColors.skipped),
title: const Text('Skip for now'),
onTap: () {
Navigator.pop(ctx);
onSkip?.call();
},
),
],
),
),
),
);
}
}
/// Small coloured badge used inside [TaskCard].
class _Badge extends StatelessWidget {
final IconData icon;
final String label;
final Color color;
const _Badge({required this.icon, required this.label, required this.color});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: color.withAlpha(30),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: color),
const SizedBox(width: 4),
Text(
label,
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: color),
),
],
),
);
}
}

View File

@@ -0,0 +1,125 @@
import 'package:flutter/material.dart';
import '../theme/app_colors.dart';
/// Visual time block widget showing estimated vs actual duration.
///
/// - Horizontal bar: green (under estimate) -> yellow (near) -> soft orange (over).
/// - NO red — going over is shown gently, not punitively.
class TimeVisualizer extends StatelessWidget {
/// Estimated duration in minutes.
final int estimatedMinutes;
/// Actual duration in minutes (null if not yet completed).
final int? actualMinutes;
/// Optional height for the bars.
final double barHeight;
const TimeVisualizer({
super.key,
required this.estimatedMinutes,
this.actualMinutes,
this.barHeight = 12,
});
/// Returns a color based on actual / estimated ratio.
/// <= 0.8 -> green (under)
/// 0.8-1.1 -> teal (on time)
/// 1.1-1.5 -> yellow (slightly over)
/// > 1.5 -> soft orange (over, but gentle)
Color _barColor(double ratio) {
if (ratio <= 0.8) return AppColors.completed;
if (ratio <= 1.1) return AppColors.primary;
if (ratio <= 1.5) return AppColors.energyMedium;
return AppColors.energyHigh; // soft orange, NOT red
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final actual = actualMinutes;
final hasActual = actual != null && actual > 0;
// If no actual yet, show just the estimate bar.
final ratio = hasActual ? actual / estimatedMinutes : 0.0;
final estimateWidth = 1.0; // always full width = estimate
final actualWidth = hasActual ? ratio.clamp(0.0, 2.0) / 2.0 : 0.0;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Labels
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Estimated: $estimatedMinutes min',
style: theme.textTheme.bodySmall,
),
if (hasActual)
Text(
'Actual: $actual min',
style: theme.textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w600,
color: _barColor(ratio),
),
),
],
),
const SizedBox(height: 6),
// ── Estimate bar (background / reference) ──────────────────
ClipRRect(
borderRadius: BorderRadius.circular(barHeight / 2),
child: Stack(
children: [
// Full estimate background
Container(
height: barHeight,
width: double.infinity,
decoration: BoxDecoration(
color: AppColors.primary.withAlpha(30),
borderRadius: BorderRadius.circular(barHeight / 2),
),
),
// Actual fill
if (hasActual)
FractionallySizedBox(
widthFactor: actualWidth.clamp(0.0, estimateWidth),
child: Container(
height: barHeight,
decoration: BoxDecoration(
color: _barColor(ratio),
borderRadius: BorderRadius.circular(barHeight / 2),
),
),
),
],
),
),
// ── Gentle insight ─────────────────────────────────────────
if (hasActual) ...[
const SizedBox(height: 4),
Text(
_insightText(ratio),
style: theme.textTheme.bodySmall?.copyWith(
color: _barColor(ratio),
fontWeight: FontWeight.w500,
),
),
],
],
);
}
String _insightText(double ratio) {
if (ratio <= 0.8) return 'Finished early — nice!';
if (ratio <= 1.1) return 'Right on target.';
if (ratio <= 1.5) return 'A little over — totally fine.';
return 'Took longer than expected. That happens!';
}
}

View File

@@ -0,0 +1,151 @@
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:focusflow_shared/focusflow_shared.dart';
import '../../../../core/network/api_client.dart';
import '../../../../core/network/interceptors/auth_interceptor.dart';
import '../../../../main.dart';
// ── Events ─────────────────────────────────────────────────────────
sealed class AuthEvent extends Equatable {
const AuthEvent();
@override
List<Object?> get props => [];
}
class AuthLoginRequested extends AuthEvent {
final String email;
final String password;
const AuthLoginRequested({required this.email, required this.password});
@override
List<Object?> get props => [email, password];
}
class AuthSignupRequested extends AuthEvent {
final String displayName;
final String email;
final String password;
const AuthSignupRequested({
required this.displayName,
required this.email,
required this.password,
});
@override
List<Object?> get props => [displayName, email, password];
}
class AuthLogoutRequested extends AuthEvent {
const AuthLogoutRequested();
}
class AuthCheckRequested extends AuthEvent {}
// ── States ─────────────────────────────────────────────────────────
sealed class AuthState extends Equatable {
const AuthState();
@override
List<Object?> get props => [];
}
class AuthInitial extends AuthState {}
class AuthLoading extends AuthState {}
class AuthAuthenticated extends AuthState {
final User user;
const AuthAuthenticated(this.user);
@override
List<Object?> get props => [user];
}
class AuthUnauthenticated extends AuthState {}
class AuthError extends AuthState {
final String message;
const AuthError(this.message);
@override
List<Object?> get props => [message];
}
// ── BLoC ───────────────────────────────────────────────────────────
class AuthBloc extends Bloc<AuthEvent, AuthState> {
final ApiClient _api = getIt<ApiClient>();
AuthBloc() : super(AuthInitial()) {
on<AuthCheckRequested>(_onCheck);
on<AuthLoginRequested>(_onLogin);
on<AuthSignupRequested>(_onSignup);
on<AuthLogoutRequested>(_onLogout);
}
Future<void> _onCheck(AuthCheckRequested event, Emitter<AuthState> emit) async {
final hasToken = await AuthInterceptor.hasToken();
if (hasToken) {
// In a production app we would validate the token and fetch the user profile.
// For now we emit a placeholder user.
emit(AuthAuthenticated(
User(
id: 'local',
email: '',
displayName: 'Friend',
createdAt: DateTime.now(),
),
));
} else {
emit(AuthUnauthenticated());
}
}
Future<void> _onLogin(AuthLoginRequested event, Emitter<AuthState> emit) async {
emit(AuthLoading());
try {
final response = await _api.login(event.email, event.password);
if (response.status == 'success' && response.data != null) {
final data = response.data!;
await AuthInterceptor.saveTokens(
accessToken: data['accessToken'] as String,
refreshToken: data['refreshToken'] as String,
);
final user = User.fromJson(data['user'] as Map<String, dynamic>);
emit(AuthAuthenticated(user));
} else {
emit(AuthError(response.error?.message ?? 'Login failed'));
}
} catch (e) {
emit(AuthError('Something went wrong. Please try again.'));
}
}
Future<void> _onSignup(AuthSignupRequested event, Emitter<AuthState> emit) async {
emit(AuthLoading());
try {
final response = await _api.register(event.displayName, event.email, event.password);
if (response.status == 'success' && response.data != null) {
final data = response.data!;
await AuthInterceptor.saveTokens(
accessToken: data['accessToken'] as String,
refreshToken: data['refreshToken'] as String,
);
final user = User.fromJson(data['user'] as Map<String, dynamic>);
emit(AuthAuthenticated(user));
} else {
emit(AuthError(response.error?.message ?? 'Sign up failed'));
}
} catch (e) {
emit(AuthError('Something went wrong. Please try again.'));
}
}
Future<void> _onLogout(AuthLogoutRequested event, Emitter<AuthState> emit) async {
try {
await _api.logout();
} catch (_) {
// Best-effort server logout — clear local tokens regardless.
}
await AuthInterceptor.clearTokens();
emit(AuthUnauthenticated());
}
}

View File

@@ -0,0 +1,153 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/theme/app_colors.dart';
import '../bloc/auth_bloc.dart';
/// Clean, ADHD-friendly login screen.
///
/// Minimal fields, large touch targets, clear labels, calming tone.
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
bool _obscurePassword = true;
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
void _submit() {
if (_formKey.currentState?.validate() ?? false) {
context.read<AuthBloc>().add(AuthLoginRequested(
email: _emailController.text.trim(),
password: _passwordController.text,
));
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
body: BlocListener<AuthBloc, AuthState>(
listener: (context, state) {
if (state is AuthAuthenticated) {
context.go('/');
} else if (state is AuthError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.message)),
);
}
},
child: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 24),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// ── Logo / Title ─────────────────────────────────
Icon(Icons.self_improvement_rounded,
size: 64, color: AppColors.primary),
const SizedBox(height: 12),
Text('FocusFlow', style: theme.textTheme.headlineLarge),
const SizedBox(height: 8),
Text(
'Welcome back. Let\'s get things done — gently.',
style: theme.textTheme.bodyMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 40),
// ── Email ────────────────────────────────────────
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
decoration: const InputDecoration(
labelText: 'Email',
prefixIcon: Icon(Icons.email_outlined),
),
validator: (v) {
if (v == null || v.trim().isEmpty) return 'Email is required';
if (!v.contains('@')) return 'Enter a valid email';
return null;
},
),
const SizedBox(height: 16),
// ── Password ─────────────────────────────────────
TextFormField(
controller: _passwordController,
obscureText: _obscurePassword,
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => _submit(),
decoration: InputDecoration(
labelText: 'Password',
prefixIcon: const Icon(Icons.lock_outline),
suffixIcon: IconButton(
icon: Icon(_obscurePassword
? Icons.visibility_off_outlined
: Icons.visibility_outlined),
onPressed: () =>
setState(() => _obscurePassword = !_obscurePassword),
),
),
validator: (v) {
if (v == null || v.isEmpty) return 'Password is required';
return null;
},
),
const SizedBox(height: 28),
// ── Sign in button ───────────────────────────────
BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) {
final loading = state is AuthLoading;
return FilledButton(
onPressed: loading ? null : _submit,
child: loading
? const SizedBox(
height: 22,
width: 22,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text('Sign In'),
);
},
),
const SizedBox(height: 16),
// ── Create account link ──────────────────────────
TextButton(
onPressed: () => context.go('/signup'),
child: const Text('Don\'t have an account? Create one'),
),
],
),
),
),
),
),
),
);
}
}

View File

@@ -0,0 +1,169 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/theme/app_colors.dart';
import '../bloc/auth_bloc.dart';
/// Sign-up screen — minimal fields to reduce friction.
class SignupScreen extends StatefulWidget {
const SignupScreen({super.key});
@override
State<SignupScreen> createState() => _SignupScreenState();
}
class _SignupScreenState extends State<SignupScreen> {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
bool _obscurePassword = true;
@override
void dispose() {
_nameController.dispose();
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
void _submit() {
if (_formKey.currentState?.validate() ?? false) {
context.read<AuthBloc>().add(AuthSignupRequested(
displayName: _nameController.text.trim(),
email: _emailController.text.trim(),
password: _passwordController.text,
));
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
body: BlocListener<AuthBloc, AuthState>(
listener: (context, state) {
if (state is AuthAuthenticated) {
context.go('/onboarding');
} else if (state is AuthError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.message)),
);
}
},
child: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 24),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.emoji_nature_rounded,
size: 64, color: AppColors.primary),
const SizedBox(height: 12),
Text('Join FocusFlow', style: theme.textTheme.headlineLarge),
const SizedBox(height: 8),
Text(
'Built for brains like yours. No judgement, just support.',
style: theme.textTheme.bodyMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 40),
// ── Display name ─────────────────────────────────
TextFormField(
controller: _nameController,
textInputAction: TextInputAction.next,
decoration: const InputDecoration(
labelText: 'What should we call you?',
prefixIcon: Icon(Icons.person_outline),
),
validator: (v) {
if (v == null || v.trim().isEmpty) return 'A name helps us personalise things';
return null;
},
),
const SizedBox(height: 16),
// ── Email ────────────────────────────────────────
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
decoration: const InputDecoration(
labelText: 'Email',
prefixIcon: Icon(Icons.email_outlined),
),
validator: (v) {
if (v == null || v.trim().isEmpty) return 'Email is required';
if (!v.contains('@')) return 'Enter a valid email';
return null;
},
),
const SizedBox(height: 16),
// ── Password ─────────────────────────────────────
TextFormField(
controller: _passwordController,
obscureText: _obscurePassword,
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => _submit(),
decoration: InputDecoration(
labelText: 'Password',
prefixIcon: const Icon(Icons.lock_outline),
suffixIcon: IconButton(
icon: Icon(_obscurePassword
? Icons.visibility_off_outlined
: Icons.visibility_outlined),
onPressed: () =>
setState(() => _obscurePassword = !_obscurePassword),
),
),
validator: (v) {
if (v == null || v.length < 8) {
return 'At least 8 characters';
}
return null;
},
),
const SizedBox(height: 28),
// ── Create account button ────────────────────────
BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) {
final loading = state is AuthLoading;
return FilledButton(
onPressed: loading ? null : _submit,
child: loading
? const SizedBox(
height: 22,
width: 22,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text('Create Account'),
);
},
),
const SizedBox(height: 16),
TextButton(
onPressed: () => context.go('/login'),
child: const Text('Already have an account? Sign in'),
),
],
),
),
),
),
),
),
);
}
}

View File

@@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/theme/app_colors.dart';
/// Placeholder screen for the body doubling / virtual coworking rooms feature.
class RoomsScreen extends StatelessWidget {
const RoomsScreen({super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back_rounded),
onPressed: () => context.go('/'),
),
title: const Text('Body Doubling'),
),
body: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.groups_rounded, size: 80, color: AppColors.tertiary.withAlpha(180)),
const SizedBox(height: 24),
Text(
'Body Doubling',
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.w700,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Coming Soon',
style: theme.textTheme.titleLarge?.copyWith(
color: AppColors.tertiary,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 20),
Text(
'Body doubling is a technique where having another person nearby '
'helps you stay focused. Soon you\'ll be able to join virtual '
'coworking rooms, work alongside others in real-time, and pick '
'ambient sounds like a caf\u00e9 or rain.',
style: theme.textTheme.bodyLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 28),
OutlinedButton.icon(
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('We\'ll notify you when body doubling rooms launch!'),
),
);
},
icon: const Icon(Icons.notifications_active_outlined),
label: const Text('Notify me'),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,48 @@
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:focusflow_shared/focusflow_shared.dart';
// ── States ─────────────────────────────────────────────────────────
sealed class RewardState extends Equatable {
const RewardState();
@override
List<Object?> get props => [];
}
class RewardIdle extends RewardState {}
class RewardShowing extends RewardState {
final Reward reward;
const RewardShowing(this.reward);
@override
List<Object?> get props => [reward];
}
class RewardDismissed extends RewardState {}
// ── Cubit ──────────────────────────────────────────────────────────
/// Manages the reward overlay display lifecycle.
///
/// Flow:
/// 1. After a task is completed, call [showReward].
/// 2. The UI renders [RewardPopup] when state is [RewardShowing].
/// 3. User taps dismiss -> call [dismiss].
class RewardCubit extends Cubit<RewardState> {
RewardCubit() : super(RewardIdle());
/// Show a reward to the user.
void showReward(Reward reward) {
emit(RewardShowing(reward));
}
/// Dismiss the current reward and return to idle.
void dismiss() {
emit(RewardDismissed());
// Brief delay then return to idle so the cubit is ready for the next reward.
Future<void>.delayed(const Duration(milliseconds: 300), () {
if (!isClosed) emit(RewardIdle());
});
}
}

View File

@@ -0,0 +1,196 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../features/auth/presentation/bloc/auth_bloc.dart';
/// Settings screen.
///
/// - Notification preferences.
/// - Daily task load slider (1-10).
/// - Focus session length.
/// - Reward style (playful / minimal / data).
/// - Forgiveness toggle.
/// - Theme (light / dark / system).
/// - Account management.
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
// ── Local preference state ───────────────────────────────────────
bool _notificationsEnabled = true;
double _taskLoad = 5;
double _focusMinutes = 25;
String _rewardStyle = 'playful';
bool _forgivenessEnabled = true;
ThemeMode _themeMode = ThemeMode.system;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back_rounded),
onPressed: () => context.go('/'),
),
title: const Text('Settings'),
),
body: ListView(
padding: const EdgeInsets.symmetric(vertical: 8),
children: [
// ── Notifications ────────────────────────────────────────
_SectionTitle('Notifications'),
SwitchListTile(
title: const Text('Enable notifications'),
subtitle: const Text('Gentle reminders, never nagging'),
value: _notificationsEnabled,
onChanged: (v) => setState(() => _notificationsEnabled = v),
),
const Divider(),
// ── Daily task load ──────────────────────────────────────
_SectionTitle('Daily Task Load'),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
Expanded(
child: Slider(
value: _taskLoad,
min: 1,
max: 10,
divisions: 9,
label: _taskLoad.round().toString(),
onChanged: (v) => setState(() => _taskLoad = v),
),
),
Text(
'${_taskLoad.round()} tasks',
style: theme.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w600),
),
],
),
),
const Divider(),
// ── Focus session length ─────────────────────────────────
_SectionTitle('Focus Session Length'),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
Expanded(
child: Slider(
value: _focusMinutes,
min: 5,
max: 60,
divisions: 11,
label: '${_focusMinutes.round()} min',
onChanged: (v) => setState(() => _focusMinutes = v),
),
),
Text(
'${_focusMinutes.round()} min',
style: theme.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w600),
),
],
),
),
const Divider(),
// ── Reward style ─────────────────────────────────────────
_SectionTitle('Reward Style'),
RadioListTile<String>(
title: const Text('Playful (animations & messages)'),
value: 'playful',
groupValue: _rewardStyle,
onChanged: (v) => setState(() => _rewardStyle = v!),
),
RadioListTile<String>(
title: const Text('Minimal (subtle feedback)'),
value: 'minimal',
groupValue: _rewardStyle,
onChanged: (v) => setState(() => _rewardStyle = v!),
),
RadioListTile<String>(
title: const Text('Data-driven (stats & charts)'),
value: 'data',
groupValue: _rewardStyle,
onChanged: (v) => setState(() => _rewardStyle = v!),
),
const Divider(),
// ── Forgiveness toggle ───────────────────────────────────
_SectionTitle('Forgiveness'),
SwitchListTile(
title: const Text('Enable grace days'),
subtitle: const Text('Missed a day? Grace days protect your streaks.'),
value: _forgivenessEnabled,
onChanged: (v) => setState(() => _forgivenessEnabled = v),
),
const Divider(),
// ── Theme ────────────────────────────────────────────────
_SectionTitle('Theme'),
RadioListTile<ThemeMode>(
title: const Text('System default'),
value: ThemeMode.system,
groupValue: _themeMode,
onChanged: (v) => setState(() => _themeMode = v!),
),
RadioListTile<ThemeMode>(
title: const Text('Light'),
value: ThemeMode.light,
groupValue: _themeMode,
onChanged: (v) => setState(() => _themeMode = v!),
),
RadioListTile<ThemeMode>(
title: const Text('Dark'),
value: ThemeMode.dark,
groupValue: _themeMode,
onChanged: (v) => setState(() => _themeMode = v!),
),
const Divider(),
// ── Account ──────────────────────────────────────────────
_SectionTitle('Account'),
ListTile(
leading: const Icon(Icons.logout_rounded, color: AppColors.error),
title: const Text('Sign out'),
onTap: () {
context.read<AuthBloc>().add(const AuthLogoutRequested());
context.go('/login');
},
),
const SizedBox(height: 40),
],
),
);
}
}
class _SectionTitle extends StatelessWidget {
final String title;
const _SectionTitle(this.title);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: Text(
title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: AppColors.primary,
fontWeight: FontWeight.w700,
),
),
);
}
}

View File

@@ -0,0 +1,120 @@
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:focusflow_shared/focusflow_shared.dart';
import '../../../../core/network/api_client.dart';
import '../../../../main.dart';
// ── Events ─────────────────────────────────────────────────────────
sealed class StreakEvent extends Equatable {
const StreakEvent();
@override
List<Object?> get props => [];
}
class StreaksLoaded extends StreakEvent {
const StreaksLoaded();
}
class StreakCompleted extends StreakEvent {
final String id;
const StreakCompleted(this.id);
@override
List<Object?> get props => [id];
}
class StreakForgiven extends StreakEvent {
final String id;
const StreakForgiven(this.id);
@override
List<Object?> get props => [id];
}
class StreakFrozen extends StreakEvent {
final String id;
final DateTime until;
const StreakFrozen(this.id, this.until);
@override
List<Object?> get props => [id, until];
}
// ── States ─────────────────────────────────────────────────────────
sealed class StreakState extends Equatable {
const StreakState();
@override
List<Object?> get props => [];
}
class StreakInitial extends StreakState {}
class StreakLoading extends StreakState {}
class StreakLoaded extends StreakState {
final List<Streak> streaks;
const StreakLoaded(this.streaks);
@override
List<Object?> get props => [streaks];
}
class StreakError extends StreakState {
final String message;
const StreakError(this.message);
@override
List<Object?> get props => [message];
}
// ── BLoC ───────────────────────────────────────────────────────────
class StreakBloc extends Bloc<StreakEvent, StreakState> {
final ApiClient _api = getIt<ApiClient>();
StreakBloc() : super(StreakInitial()) {
on<StreaksLoaded>(_onLoaded);
on<StreakCompleted>(_onCompleted);
on<StreakForgiven>(_onForgiven);
on<StreakFrozen>(_onFrozen);
}
Future<void> _onLoaded(StreaksLoaded event, Emitter<StreakState> emit) async {
emit(StreakLoading());
try {
final response = await _api.fetchStreaks();
final data = response.data;
if (data != null && data['status'] == 'success') {
final items = (data['data'] as List<dynamic>)
.map((e) => Streak.fromJson(e as Map<String, dynamic>))
.toList();
emit(StreakLoaded(items));
} else {
emit(const StreakError('Could not load streaks.'));
}
} catch (_) {
emit(const StreakError('Something went wrong loading streaks.'));
}
}
Future<void> _onCompleted(StreakCompleted event, Emitter<StreakState> emit) async {
try {
await _api.completeStreak(event.id);
add(const StreaksLoaded());
} catch (_) {
emit(const StreakError('Could not complete streak entry.'));
}
}
Future<void> _onForgiven(StreakForgiven event, Emitter<StreakState> emit) async {
try {
await _api.forgiveStreak(event.id);
add(const StreaksLoaded());
} catch (_) {
emit(const StreakError('Could not forgive streak.'));
}
}
Future<void> _onFrozen(StreakFrozen event, Emitter<StreakState> emit) async {
// TODO: API call to freeze streak until `event.until`.
add(const StreaksLoaded());
}
}

View File

@@ -0,0 +1,229 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:focusflow_shared/focusflow_shared.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/widgets/streak_ring.dart';
import '../bloc/streak_bloc.dart';
/// Streak overview screen.
///
/// - Active streaks as cards with [StreakRing] widget.
/// - "Frozen" indicator for paused streaks.
/// - Tap to see history.
/// - "New Streak" FAB.
/// - Grace day info shown positively.
class StreaksScreen extends StatefulWidget {
const StreaksScreen({super.key});
@override
State<StreaksScreen> createState() => _StreaksScreenState();
}
class _StreaksScreenState extends State<StreaksScreen> {
late final StreakBloc _bloc;
@override
void initState() {
super.initState();
_bloc = StreakBloc()..add(const StreaksLoaded());
}
@override
void dispose() {
_bloc.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return BlocProvider.value(
value: _bloc,
child: Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back_rounded),
onPressed: () => context.go('/'),
),
title: const Text('Your Streaks'),
),
body: BlocBuilder<StreakBloc, StreakState>(
builder: (context, state) {
if (state is StreakLoading) {
return const Center(child: CircularProgressIndicator());
}
if (state is StreakError) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.cloud_off_rounded, size: 48, color: AppColors.skipped),
const SizedBox(height: 12),
Text(state.message, style: theme.textTheme.bodyLarge, textAlign: TextAlign.center),
const SizedBox(height: 16),
FilledButton(
onPressed: () => _bloc.add(const StreaksLoaded()),
child: const Text('Retry'),
),
],
),
),
);
}
if (state is StreakLoaded) {
if (state.streaks.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.local_fire_department_rounded,
size: 56, color: AppColors.tertiary),
const SizedBox(height: 12),
Text('No streaks yet',
style: theme.textTheme.titleMedium),
const SizedBox(height: 4),
Text(
'Start a streak to build consistency — with grace days built in.',
style: theme.textTheme.bodyMedium,
textAlign: TextAlign.center,
),
],
),
),
);
}
return ListView.builder(
padding: const EdgeInsets.only(top: 8, bottom: 100),
itemCount: state.streaks.length,
itemBuilder: (context, index) {
final streak = state.streaks[index];
return _StreakCard(
streak: streak,
onComplete: () => _bloc.add(StreakCompleted(streak.id)),
);
},
);
}
return const SizedBox.shrink();
},
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () {
// TODO: navigate to create streak screen.
},
icon: const Icon(Icons.add_rounded),
label: const Text('New Streak'),
),
),
);
}
}
class _StreakCard extends StatelessWidget {
final Streak streak;
final VoidCallback onComplete;
const _StreakCard({required this.streak, required this.onComplete});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isFrozen = streak.frozenUntil != null &&
streak.frozenUntil!.isAfter(DateTime.now());
final graceDaysRemaining = streak.graceDays - streak.graceUsed;
return Card(
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () {
// TODO: navigate to streak history.
},
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
StreakRing(
currentCount: streak.currentCount,
graceDaysRemaining: graceDaysRemaining,
totalGraceDays: streak.graceDays,
isFrozen: isFrozen,
size: 72,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
streak.name,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
if (isFrozen)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: AppColors.primaryLight.withAlpha(40),
borderRadius: BorderRadius.circular(12),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.ac_unit_rounded, size: 14, color: AppColors.primaryLight),
SizedBox(width: 4),
Text('Frozen',
style: TextStyle(fontSize: 12, color: AppColors.primaryLight)),
],
),
),
],
),
const SizedBox(height: 4),
Text(
'Current: ${streak.currentCount} days | Best: ${streak.longestCount}',
style: theme.textTheme.bodySmall,
),
if (graceDaysRemaining > 0 && !isFrozen)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
'$graceDaysRemaining grace days remaining',
style: theme.textTheme.bodySmall?.copyWith(
color: AppColors.primary,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
if (!isFrozen)
IconButton(
icon: const Icon(Icons.check_circle_outline_rounded,
color: AppColors.completed),
onPressed: onComplete,
tooltip: 'Complete today',
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,84 @@
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:focusflow_shared/focusflow_shared.dart';
import '../../../../core/network/api_client.dart';
import '../../../../main.dart';
// ── States ─────────────────────────────────────────────────────────
sealed class NextTaskState extends Equatable {
const NextTaskState();
@override
List<Object?> get props => [];
}
class NextTaskLoading extends NextTaskState {}
class NextTaskLoaded extends NextTaskState {
final Task task;
const NextTaskLoaded(this.task);
@override
List<Object?> get props => [task];
}
class NextTaskEmpty extends NextTaskState {}
class NextTaskError extends NextTaskState {
final String message;
const NextTaskError(this.message);
@override
List<Object?> get props => [message];
}
// ── Cubit ──────────────────────────────────────────────────────────
/// Simple cubit for focus mode: "just do the next thing."
///
/// Loads the single highest-priority task from the API.
class NextTaskCubit extends Cubit<NextTaskState> {
final ApiClient _api = getIt<ApiClient>();
NextTaskCubit() : super(NextTaskLoading());
/// Fetch the next recommended task.
Future<void> loadNext() async {
emit(NextTaskLoading());
try {
final response = await _api.fetchNextTask();
final data = response.data;
if (data != null && data['status'] == 'success' && data['data'] != null) {
final task = Task.fromJson(data['data'] as Map<String, dynamic>);
emit(NextTaskLoaded(task));
} else {
emit(NextTaskEmpty());
}
} catch (_) {
emit(const NextTaskError('Could not load your next task.'));
}
}
/// Mark the current task as done, then load the next one.
Future<void> complete() async {
final current = state;
if (current is! NextTaskLoaded) return;
try {
await _api.completeTask(current.task.id);
} catch (_) {
// Best-effort — still load next.
}
await loadNext();
}
/// Skip the current task, then load the next one.
Future<void> skip() async {
final current = state;
if (current is! NextTaskLoaded) return;
try {
await _api.skipTask(current.task.id);
} catch (_) {
// Best-effort.
}
await loadNext();
}
}

View File

@@ -0,0 +1,141 @@
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:focusflow_shared/focusflow_shared.dart';
import '../../../../core/network/api_client.dart';
import '../../../../main.dart';
// ── Events ─────────────────────────────────────────────────────────
sealed class TaskListEvent extends Equatable {
const TaskListEvent();
@override
List<Object?> get props => [];
}
class TasksLoaded extends TaskListEvent {
const TasksLoaded();
}
class TaskCompleted extends TaskListEvent {
final String id;
const TaskCompleted(this.id);
@override
List<Object?> get props => [id];
}
class TaskSkipped extends TaskListEvent {
final String id;
const TaskSkipped(this.id);
@override
List<Object?> get props => [id];
}
class TaskCreated extends TaskListEvent {
final Task task;
const TaskCreated(this.task);
@override
List<Object?> get props => [task];
}
class TaskDeleted extends TaskListEvent {
final String id;
const TaskDeleted(this.id);
@override
List<Object?> get props => [id];
}
// ── States ─────────────────────────────────────────────────────────
sealed class TaskListState extends Equatable {
const TaskListState();
@override
List<Object?> get props => [];
}
class TaskListInitial extends TaskListState {}
class TaskListLoading extends TaskListState {}
class TaskListLoaded extends TaskListState {
final List<Task> tasks;
const TaskListLoaded(this.tasks);
@override
List<Object?> get props => [tasks];
}
class TaskListError extends TaskListState {
final String message;
const TaskListError(this.message);
@override
List<Object?> get props => [message];
}
// ── BLoC ───────────────────────────────────────────────────────────
class TaskListBloc extends Bloc<TaskListEvent, TaskListState> {
final ApiClient _api = getIt<ApiClient>();
TaskListBloc() : super(TaskListInitial()) {
on<TasksLoaded>(_onLoaded);
on<TaskCompleted>(_onCompleted);
on<TaskSkipped>(_onSkipped);
on<TaskCreated>(_onCreated);
on<TaskDeleted>(_onDeleted);
}
Future<void> _onLoaded(TasksLoaded event, Emitter<TaskListState> emit) async {
emit(TaskListLoading());
try {
final response = await _api.fetchTasks();
final data = response.data;
if (data != null && data['status'] == 'success') {
final items = (data['data'] as List<dynamic>)
.map((e) => Task.fromJson(e as Map<String, dynamic>))
.toList();
emit(TaskListLoaded(items));
} else {
emit(const TaskListError('Could not load tasks.'));
}
} catch (_) {
emit(const TaskListError('Something went wrong loading tasks.'));
}
}
Future<void> _onCompleted(TaskCompleted event, Emitter<TaskListState> emit) async {
try {
await _api.completeTask(event.id);
// Re-fetch after completion so the list is up to date.
add(const TasksLoaded());
} catch (_) {
emit(const TaskListError('Could not complete task.'));
}
}
Future<void> _onSkipped(TaskSkipped event, Emitter<TaskListState> emit) async {
try {
await _api.skipTask(event.id);
add(const TasksLoaded());
} catch (_) {
emit(const TaskListError('Could not skip task.'));
}
}
Future<void> _onCreated(TaskCreated event, Emitter<TaskListState> emit) async {
try {
await _api.createTask(event.task.toJson());
add(const TasksLoaded());
} catch (_) {
emit(const TaskListError('Could not create task.'));
}
}
Future<void> _onDeleted(TaskDeleted event, Emitter<TaskListState> emit) async {
try {
await _api.deleteTask(event.id);
add(const TasksLoaded());
} catch (_) {
emit(const TaskListError('Could not delete task.'));
}
}
}

View File

@@ -0,0 +1,120 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../widgets/energy_selector.dart';
/// Quick task creation screen — minimal friction.
///
/// Fields:
/// - Title (required).
/// - Energy level (default medium).
/// - Estimated minutes.
/// - Tags.
class CreateTaskScreen extends StatefulWidget {
const CreateTaskScreen({super.key});
@override
State<CreateTaskScreen> createState() => _CreateTaskScreenState();
}
class _CreateTaskScreenState extends State<CreateTaskScreen> {
final _formKey = GlobalKey<FormState>();
final _titleController = TextEditingController();
final _minutesController = TextEditingController(text: '15');
final _tagsController = TextEditingController();
String _energyLevel = 'medium';
@override
void dispose() {
_titleController.dispose();
_minutesController.dispose();
_tagsController.dispose();
super.dispose();
}
void _create() {
if (_formKey.currentState?.validate() ?? false) {
// TODO: dispatch TaskCreated via BLoC.
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Task created!')),
);
context.pop();
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(title: const Text('New Task')),
body: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'What do you need to do?',
style: theme.textTheme.titleLarge,
),
const SizedBox(height: 16),
// ── Title ──────────────────────────────────────────
TextFormField(
controller: _titleController,
autofocus: true,
textInputAction: TextInputAction.next,
decoration: const InputDecoration(
labelText: 'Task title',
hintText: 'e.g. Reply to email',
),
validator: (v) =>
(v == null || v.trim().isEmpty) ? 'What is it?' : null,
),
const SizedBox(height: 24),
// ── Energy level ───────────────────────────────────
Text('How much energy will this take?',
style: theme.textTheme.titleSmall),
const SizedBox(height: 8),
EnergySelector(
value: _energyLevel,
onChanged: (v) => setState(() => _energyLevel = v),
),
const SizedBox(height: 24),
// ── Estimated minutes ──────────────────────────────
TextFormField(
controller: _minutesController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Estimated minutes',
suffixText: 'min',
),
),
const SizedBox(height: 24),
// ── Tags ───────────────────────────────────────────
TextFormField(
controller: _tagsController,
decoration: const InputDecoration(
labelText: 'Tags (optional, comma separated)',
prefixIcon: Icon(Icons.label_outline),
),
),
const SizedBox(height: 32),
// ── Create button ──────────────────────────────────
FilledButton(
onPressed: _create,
child: const Text('Create Task'),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,391 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:focusflow_shared/focusflow_shared.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/widgets/reward_popup.dart';
import '../bloc/next_task_cubit.dart';
/// THE core ADHD feature — "Just do the next thing."
///
/// - Single task displayed, nothing else.
/// - Large title text centered.
/// - Energy level indicator.
/// - Estimated time.
/// - Big "Done!" button (green, satisfying).
/// - "Skip" button (smaller, gray, no guilt).
/// - "I need a break" option.
/// - Timer showing elapsed time (gentle, not anxiety-inducing).
/// - On completion: reward popup appears.
/// - After reward: auto-loads next task or shows "All done!" celebration.
class FocusModeScreen extends StatefulWidget {
const FocusModeScreen({super.key});
@override
State<FocusModeScreen> createState() => _FocusModeScreenState();
}
class _FocusModeScreenState extends State<FocusModeScreen> {
late final NextTaskCubit _cubit;
final Stopwatch _stopwatch = Stopwatch();
Timer? _timer;
bool _showReward = false;
int _elapsedSeconds = 0;
@override
void initState() {
super.initState();
_cubit = NextTaskCubit()..loadNext();
_startTimer();
}
void _startTimer() {
_stopwatch.start();
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
if (mounted) {
setState(() => _elapsedSeconds = _stopwatch.elapsed.inSeconds);
}
});
}
void _resetTimer() {
_stopwatch.reset();
setState(() => _elapsedSeconds = 0);
}
@override
void dispose() {
_timer?.cancel();
_stopwatch.stop();
_cubit.close();
super.dispose();
}
String _formatElapsed() {
final minutes = _elapsedSeconds ~/ 60;
final seconds = _elapsedSeconds % 60;
return '${minutes}m ${seconds.toString().padLeft(2, '0')}s so far';
}
Color _energyColor(String level) {
switch (level) {
case 'low':
return AppColors.energyLow;
case 'high':
return AppColors.energyHigh;
default:
return AppColors.energyMedium;
}
}
String _energyEmoji(String level) {
switch (level) {
case 'low':
return 'Low energy';
case 'high':
return 'High energy';
default:
return 'Medium energy';
}
}
Future<void> _onComplete() async {
_stopwatch.stop();
setState(() => _showReward = true);
}
void _dismissReward() {
setState(() => _showReward = false);
_resetTimer();
_stopwatch.start();
_cubit.complete();
}
void _onSkip() {
_resetTimer();
_cubit.skip();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return BlocProvider.value(
value: _cubit,
child: Scaffold(
backgroundColor: theme.scaffoldBackgroundColor,
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.close_rounded),
onPressed: () => context.go('/'),
tooltip: 'Exit focus mode',
),
title: const Text('Focus Mode'),
centerTitle: true,
),
body: Stack(
children: [
BlocBuilder<NextTaskCubit, NextTaskState>(
builder: (context, state) {
if (state is NextTaskLoading) {
return const Center(child: CircularProgressIndicator());
}
if (state is NextTaskError) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.cloud_off_rounded,
size: 56, color: AppColors.skipped),
const SizedBox(height: 12),
Text(state.message,
style: theme.textTheme.bodyLarge,
textAlign: TextAlign.center),
const SizedBox(height: 16),
FilledButton(
onPressed: () => _cubit.loadNext(),
child: const Text('Try again'),
),
],
),
),
);
}
if (state is NextTaskEmpty) {
return _AllDoneView(onGoBack: () => context.go('/'));
}
if (state is NextTaskLoaded) {
return _TaskFocusView(
task: state.task,
elapsed: _formatElapsed(),
energyColor: _energyColor(state.task.energyLevel),
energyLabel: _energyEmoji(state.task.energyLevel),
onComplete: _onComplete,
onSkip: _onSkip,
onBreak: () {
_stopwatch.stop();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text(
'Take your time. Tap anywhere when you\'re ready.'),
action: SnackBarAction(
label: 'Resume',
onPressed: () => _stopwatch.start(),
),
),
);
},
);
}
return const SizedBox.shrink();
},
),
// ── Reward overlay ───────────────────────────────────
if (_showReward)
Container(
color: Colors.black.withAlpha(120),
child: RewardPopup(
reward: Reward(
id: 'local-${DateTime.now().millisecondsSinceEpoch}',
userId: '',
rewardType: 'points',
magnitude: 10,
title: 'Task complete!',
createdAt: DateTime.now(),
),
onDismiss: _dismissReward,
),
),
],
),
),
);
}
}
// ── Single-task focus view ─────────────────────────────────────────
class _TaskFocusView extends StatelessWidget {
final Task task;
final String elapsed;
final Color energyColor;
final String energyLabel;
final VoidCallback onComplete;
final VoidCallback onSkip;
final VoidCallback onBreak;
const _TaskFocusView({
required this.task,
required this.elapsed,
required this.energyColor,
required this.energyLabel,
required this.onComplete,
required this.onSkip,
required this.onBreak,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 28),
child: Column(
children: [
const Spacer(flex: 2),
// ── Energy indicator ─────────────────────────────────
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
decoration: BoxDecoration(
color: energyColor.withAlpha(30),
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.bolt_rounded, size: 18, color: energyColor),
const SizedBox(width: 4),
Text(energyLabel,
style: TextStyle(
color: energyColor, fontWeight: FontWeight.w600)),
],
),
),
const SizedBox(height: 24),
// ── Task title ───────────────────────────────────────
Text(
task.title,
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.w700,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
// ── Estimated time ───────────────────────────────────
if (task.estimatedMinutes != null)
Text(
'~${task.estimatedMinutes} min estimated',
style: theme.textTheme.bodyLarge?.copyWith(
color: AppColors.primary,
),
),
const SizedBox(height: 20),
// ── Elapsed time (gentle) ────────────────────────────
Text(
elapsed,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
color: AppColors.primary,
),
),
const Spacer(flex: 3),
// ── Done button ──────────────────────────────────────
SizedBox(
width: double.infinity,
height: 64,
child: FilledButton(
onPressed: onComplete,
style: FilledButton.styleFrom(
backgroundColor: AppColors.completed,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
textStyle: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
child: const Text('Done!', style: TextStyle(fontSize: 22, color: Colors.white)),
),
),
const SizedBox(height: 12),
// ── Skip button (smaller, gray, no guilt) ────────────
SizedBox(
width: double.infinity,
height: 52,
child: OutlinedButton(
onPressed: onSkip,
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.skipped,
side: const BorderSide(color: AppColors.skipped),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
child: const Text('Skip for now'),
),
),
const SizedBox(height: 8),
// ── Break button ─────────────────────────────────────
TextButton.icon(
onPressed: onBreak,
icon: const Icon(Icons.self_improvement_rounded, size: 20),
label: const Text('I need a break'),
),
const Spacer(),
],
),
),
);
}
}
// ── All done celebration ───────────────────────────────────────────
class _AllDoneView extends StatelessWidget {
final VoidCallback onGoBack;
const _AllDoneView({required this.onGoBack});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.celebration_rounded, size: 80, color: AppColors.rewardGold),
const SizedBox(height: 20),
Text(
'All done for today!',
style: theme.textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.w700),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'You showed up and that matters. Enjoy your free time!',
style: theme.textTheme.bodyLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 28),
FilledButton(
onPressed: onGoBack,
child: const Text('Back to dashboard'),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,362 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/theme/app_colors.dart';
import '../widgets/energy_selector.dart';
/// ADHD-specific onboarding flow (4 steps).
///
/// 1. Welcome — "Built for brains like yours."
/// 2. How it works — focus mode, rewards, forgiveness.
/// 3. Preferences — daily task load, energy preference, focus session length.
/// 4. Ready — "Let's do this! No pressure."
class OnboardingScreen extends StatefulWidget {
const OnboardingScreen({super.key});
@override
State<OnboardingScreen> createState() => _OnboardingScreenState();
}
class _OnboardingScreenState extends State<OnboardingScreen> {
final PageController _pageController = PageController();
int _currentPage = 0;
// ── Preference values ────────────────────────────────────────────
double _taskLoad = 5;
String _energyPreference = 'medium';
double _focusMinutes = 25;
void _next() {
if (_currentPage < 3) {
_pageController.nextPage(
duration: const Duration(milliseconds: 350),
curve: Curves.easeInOut,
);
} else {
// Onboarding complete — go home.
context.go('/');
}
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
body: SafeArea(
child: Column(
children: [
// ── Page indicator ────────────────────────────────────
Padding(
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 28),
child: Row(
children: List.generate(4, (i) {
final active = i <= _currentPage;
return Expanded(
child: Container(
height: 4,
margin: const EdgeInsets.symmetric(horizontal: 3),
decoration: BoxDecoration(
color: active ? AppColors.primary : AppColors.skipped.withAlpha(80),
borderRadius: BorderRadius.circular(2),
),
),
);
}),
),
),
// ── Pages ────────────────────────────────────────────
Expanded(
child: PageView(
controller: _pageController,
onPageChanged: (i) => setState(() => _currentPage = i),
physics: const NeverScrollableScrollPhysics(),
children: [
_WelcomePage(theme: theme),
_HowItWorksPage(theme: theme),
_PreferencesPage(
theme: theme,
taskLoad: _taskLoad,
onTaskLoadChanged: (v) => setState(() => _taskLoad = v),
energyPreference: _energyPreference,
onEnergyChanged: (v) => setState(() => _energyPreference = v),
focusMinutes: _focusMinutes,
onFocusChanged: (v) => setState(() => _focusMinutes = v),
),
_ReadyPage(theme: theme),
],
),
),
// ── Bottom button ────────────────────────────────────
Padding(
padding: const EdgeInsets.fromLTRB(28, 8, 28, 24),
child: FilledButton(
onPressed: _next,
child: Text(_currentPage == 3 ? 'Let\'s go!' : 'Continue'),
),
),
],
),
),
);
}
}
// ── Step 1: Welcome ────────────────────────────────────────────────
class _WelcomePage extends StatelessWidget {
final ThemeData theme;
const _WelcomePage({required this.theme});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 28),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.self_improvement_rounded, size: 80, color: AppColors.primary),
const SizedBox(height: 24),
Text(
'Built for brains like yours',
style: theme.textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.w700),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Text(
'FocusFlow is designed with ADHD in mind. '
'No overwhelming lists, no guilt — just gentle support '
'to help you get things done, one task at a time.',
style: theme.textTheme.bodyLarge,
textAlign: TextAlign.center,
),
],
),
);
}
}
// ── Step 2: How it works ───────────────────────────────────────────
class _HowItWorksPage extends StatelessWidget {
final ThemeData theme;
const _HowItWorksPage({required this.theme});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 28),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'How it works',
style: theme.textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.w700),
textAlign: TextAlign.center,
),
const SizedBox(height: 28),
_FeatureRow(
icon: Icons.center_focus_strong_rounded,
color: AppColors.primary,
title: 'Focus Mode',
subtitle: 'One task at a time. No distractions.',
),
const SizedBox(height: 20),
_FeatureRow(
icon: Icons.celebration_rounded,
color: AppColors.secondary,
title: 'Rewards',
subtitle: 'Earn points & celebrations for completing tasks.',
),
const SizedBox(height: 20),
_FeatureRow(
icon: Icons.favorite_rounded,
color: AppColors.tertiary,
title: 'Forgiveness',
subtitle: 'Missed a day? Grace days have your back. No guilt.',
),
],
),
);
}
}
class _FeatureRow extends StatelessWidget {
final IconData icon;
final Color color;
final String title;
final String subtitle;
const _FeatureRow({
required this.icon,
required this.color,
required this.title,
required this.subtitle,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: color.withAlpha(30),
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, color: color, size: 28),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600)),
const SizedBox(height: 2),
Text(subtitle, style: theme.textTheme.bodyMedium),
],
),
),
],
);
}
}
// ── Step 3: Preferences ────────────────────────────────────────────
class _PreferencesPage extends StatelessWidget {
final ThemeData theme;
final double taskLoad;
final ValueChanged<double> onTaskLoadChanged;
final String energyPreference;
final ValueChanged<String> onEnergyChanged;
final double focusMinutes;
final ValueChanged<double> onFocusChanged;
const _PreferencesPage({
required this.theme,
required this.taskLoad,
required this.onTaskLoadChanged,
required this.energyPreference,
required this.onEnergyChanged,
required this.focusMinutes,
required this.onFocusChanged,
});
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 28),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 20),
Text(
'Set your preferences',
style: theme.textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 8),
Text(
'You can always change these later.',
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 28),
// Daily task load
Text('How many tasks per day feel right?', style: theme.textTheme.titleSmall),
const SizedBox(height: 4),
Row(
children: [
Expanded(
child: Slider(
value: taskLoad,
min: 1,
max: 10,
divisions: 9,
label: taskLoad.round().toString(),
onChanged: onTaskLoadChanged,
),
),
Text(
'${taskLoad.round()}',
style: theme.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
),
],
),
const SizedBox(height: 24),
// Energy preference
Text('What\'s your usual energy level?', style: theme.textTheme.titleSmall),
const SizedBox(height: 8),
EnergySelector(value: energyPreference, onChanged: onEnergyChanged),
const SizedBox(height: 24),
// Focus session length
Text('Focus session length', style: theme.textTheme.titleSmall),
const SizedBox(height: 4),
Row(
children: [
Expanded(
child: Slider(
value: focusMinutes,
min: 5,
max: 60,
divisions: 11,
label: '${focusMinutes.round()} min',
onChanged: onFocusChanged,
),
),
Text(
'${focusMinutes.round()} min',
style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600),
),
],
),
],
),
);
}
}
// ── Step 4: Ready ──────────────────────────────────────────────────
class _ReadyPage extends StatelessWidget {
final ThemeData theme;
const _ReadyPage({required this.theme});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 28),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.rocket_launch_rounded, size: 80, color: AppColors.primary),
const SizedBox(height: 24),
Text(
'You\'re all set!',
style: theme.textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.w700),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Text(
'Let\'s do this. No pressure — start with one task '
'and see how it feels. You\'ve got this.',
style: theme.textTheme.bodyLarge,
textAlign: TextAlign.center,
),
],
),
);
}
}

View File

@@ -0,0 +1,303 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/widgets/gentle_nudge_card.dart';
import '../../../../core/widgets/streak_ring.dart';
import '../../../../core/widgets/task_card.dart';
import '../../../../features/auth/presentation/bloc/auth_bloc.dart';
import '../bloc/task_list_bloc.dart';
/// Home screen — the task dashboard.
///
/// Layout:
/// - AppBar greeting.
/// - Focus Mode prominent card at the top.
/// - Today's tasks list (limited to preferredTaskLoad).
/// - Streak summary cards (horizontal scroll).
/// - Bottom nav: Tasks, Streaks, Time, Settings.
class TaskDashboardScreen extends StatefulWidget {
const TaskDashboardScreen({super.key});
@override
State<TaskDashboardScreen> createState() => _TaskDashboardScreenState();
}
class _TaskDashboardScreenState extends State<TaskDashboardScreen> {
late final TaskListBloc _taskListBloc;
int _currentNavIndex = 0;
@override
void initState() {
super.initState();
_taskListBloc = TaskListBloc()..add(const TasksLoaded());
}
@override
void dispose() {
_taskListBloc.close();
super.dispose();
}
String _greeting(AuthState authState) {
final name = authState is AuthAuthenticated ? authState.user.displayName : 'Friend';
return 'Hey $name! What shall we tackle?';
}
void _onNavTap(int index) {
switch (index) {
case 0:
break; // already here
case 1:
context.go('/streaks');
case 2:
context.go('/time');
case 3:
context.go('/settings');
}
setState(() => _currentNavIndex = index);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return BlocProvider.value(
value: _taskListBloc,
child: Scaffold(
// ── AppBar ───────────────────────────────────────────────
appBar: AppBar(
title: BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) => Text(
_greeting(state),
style: theme.textTheme.titleMedium,
),
),
actions: [
IconButton(
icon: const Icon(Icons.notifications_none_rounded),
onPressed: () {},
tooltip: 'Notifications',
),
],
),
// ── Body ─────────────────────────────────────────────────
body: RefreshIndicator(
onRefresh: () async => _taskListBloc.add(const TasksLoaded()),
child: ListView(
padding: const EdgeInsets.only(bottom: 100),
children: [
// Focus Mode card
_FocusModeCard(onTap: () => context.go('/focus')),
// Gentle nudge (shown conditionally)
GentleNudgeCard(
previousStreak: 7,
onStartSmall: () => context.go('/focus'),
onDismiss: () {},
),
// ── Streak rings (horizontal scroll) ───────────────
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text('Your Streaks', style: theme.textTheme.titleMedium),
),
SizedBox(
height: 120,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12),
children: const [
Padding(
padding: EdgeInsets.symmetric(horizontal: 6),
child: StreakRing(
currentCount: 12,
graceDaysRemaining: 2,
totalGraceDays: 2,
label: 'Daily tasks',
),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 6),
child: StreakRing(
currentCount: 5,
graceDaysRemaining: 1,
totalGraceDays: 2,
label: 'Exercise',
),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 6),
child: StreakRing(
currentCount: 0,
isFrozen: true,
label: 'Frozen',
),
),
],
),
),
// ── Today's tasks ──────────────────────────────────
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text('Today\'s Tasks', style: theme.textTheme.titleMedium),
),
BlocBuilder<TaskListBloc, TaskListState>(
builder: (context, state) {
if (state is TaskListLoading) {
return const Center(
child: Padding(
padding: EdgeInsets.all(32),
child: CircularProgressIndicator(),
),
);
}
if (state is TaskListError) {
return _EmptyState(
icon: Icons.cloud_off_rounded,
title: 'Could not load tasks',
subtitle: state.message,
);
}
if (state is TaskListLoaded) {
final tasks = state.tasks;
if (tasks.isEmpty) {
return const _EmptyState(
icon: Icons.check_circle_outline_rounded,
title: 'All clear!',
subtitle: 'No tasks for today. Enjoy the calm.',
);
}
return Column(
children: tasks
.take(5)
.map((t) => TaskCard(
task: t,
onTap: () => context.go('/task/${t.id}'),
onComplete: () =>
_taskListBloc.add(TaskCompleted(t.id)),
onSkip: () =>
_taskListBloc.add(TaskSkipped(t.id)),
))
.toList(),
);
}
return const SizedBox.shrink();
},
),
],
),
),
// ── FAB: create task ─────────────────────────────────────
floatingActionButton: FloatingActionButton.extended(
onPressed: () => context.go('/task-create'),
icon: const Icon(Icons.add_rounded),
label: const Text('New Task'),
),
// ── Bottom nav ───────────────────────────────────────────
bottomNavigationBar: BottomNavigationBar(
currentIndex: _currentNavIndex,
onTap: _onNavTap,
items: const [
BottomNavigationBarItem(icon: Icon(Icons.task_alt_rounded), label: 'Tasks'),
BottomNavigationBarItem(icon: Icon(Icons.local_fire_department_rounded), label: 'Streaks'),
BottomNavigationBarItem(icon: Icon(Icons.timer_outlined), label: 'Time'),
BottomNavigationBarItem(icon: Icon(Icons.settings_rounded), label: 'Settings'),
],
),
),
);
}
}
// ── Focus Mode Card ────────────────────────────────────────────────
class _FocusModeCard extends StatelessWidget {
final VoidCallback onTap;
const _FocusModeCard({required this.onTap});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
color: AppColors.primary,
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(20),
child: Row(
children: [
const Icon(Icons.self_improvement_rounded, size: 40, color: Colors.white),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Focus Mode',
style: theme.textTheme.titleLarge?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
'Just do the next thing. One at a time.',
style: theme.textTheme.bodyMedium?.copyWith(
color: Colors.white.withAlpha(200),
),
),
],
),
),
const Icon(Icons.arrow_forward_rounded, color: Colors.white),
],
),
),
),
);
}
}
// ── Empty state ────────────────────────────────────────────────────
class _EmptyState extends StatelessWidget {
final IconData icon;
final String title;
final String subtitle;
const _EmptyState({
required this.icon,
required this.title,
required this.subtitle,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.all(32),
child: Column(
children: [
Icon(icon, size: 56, color: AppColors.skipped),
const SizedBox(height: 12),
Text(title, style: theme.textTheme.titleMedium),
const SizedBox(height: 4),
Text(subtitle, style: theme.textTheme.bodyMedium, textAlign: TextAlign.center),
],
),
);
}
}

View File

@@ -0,0 +1,220 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:focusflow_shared/focusflow_shared.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/widgets/time_visualizer.dart';
import '../widgets/energy_selector.dart';
/// Task detail / edit screen.
///
/// Shows title, description, energy level selector, time estimate,
/// tags, category. Provides save and delete actions.
class TaskDetailScreen extends StatefulWidget {
final String taskId;
const TaskDetailScreen({super.key, required this.taskId});
@override
State<TaskDetailScreen> createState() => _TaskDetailScreenState();
}
class _TaskDetailScreenState extends State<TaskDetailScreen> {
final _formKey = GlobalKey<FormState>();
final _titleController = TextEditingController();
final _descriptionController = TextEditingController();
final _minutesController = TextEditingController();
final _tagsController = TextEditingController();
final _categoryController = TextEditingController();
String _energyLevel = 'medium';
// Placeholder task — in production, load from BLoC.
Task? _task;
bool _loading = true;
@override
void initState() {
super.initState();
_loadTask();
}
Future<void> _loadTask() async {
// Simulated placeholder — in production the BLoC fetches from the API.
await Future<void>.delayed(const Duration(milliseconds: 300));
final task = Task(
id: widget.taskId,
userId: 'local',
title: 'Sample Task',
description: 'Tap to edit this task.',
energyLevel: 'medium',
estimatedMinutes: 25,
actualMinutes: 20,
tags: const ['work', 'focus'],
category: 'work',
createdAt: DateTime.now(),
);
if (!mounted) return;
setState(() {
_task = task;
_loading = false;
_titleController.text = task.title;
_descriptionController.text = task.description ?? '';
_minutesController.text = task.estimatedMinutes?.toString() ?? '';
_tagsController.text = task.tags.join(', ');
_categoryController.text = task.category ?? '';
_energyLevel = task.energyLevel;
});
}
@override
void dispose() {
_titleController.dispose();
_descriptionController.dispose();
_minutesController.dispose();
_tagsController.dispose();
_categoryController.dispose();
super.dispose();
}
void _save() {
if (_formKey.currentState?.validate() ?? false) {
// TODO: dispatch update via BLoC.
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Task saved.')),
);
context.pop();
}
}
void _delete() {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Delete task?'),
content: const Text('This cannot be undone.'),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Cancel')),
TextButton(
onPressed: () {
Navigator.pop(ctx);
// TODO: dispatch delete via BLoC.
context.pop();
},
child: const Text('Delete', style: TextStyle(color: AppColors.error)),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
if (_loading) {
return Scaffold(
appBar: AppBar(title: const Text('Task')),
body: const Center(child: CircularProgressIndicator()),
);
}
return Scaffold(
appBar: AppBar(
title: const Text('Task Detail'),
actions: [
IconButton(
icon: const Icon(Icons.delete_outline_rounded, color: AppColors.error),
onPressed: _delete,
tooltip: 'Delete',
),
],
),
body: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── Title ──────────────────────────────────────────
TextFormField(
controller: _titleController,
decoration: const InputDecoration(labelText: 'Title'),
style: theme.textTheme.titleLarge,
validator: (v) =>
(v == null || v.trim().isEmpty) ? 'Give it a name' : null,
),
const SizedBox(height: 16),
// ── Description ────────────────────────────────────
TextFormField(
controller: _descriptionController,
decoration: const InputDecoration(labelText: 'Description (optional)'),
maxLines: 3,
),
const SizedBox(height: 20),
// ── Energy level ───────────────────────────────────
Text('Energy level', style: theme.textTheme.titleSmall),
const SizedBox(height: 8),
EnergySelector(
value: _energyLevel,
onChanged: (v) => setState(() => _energyLevel = v),
),
const SizedBox(height: 20),
// ── Time estimate ──────────────────────────────────
TextFormField(
controller: _minutesController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Estimated minutes',
suffixText: 'min',
),
),
const SizedBox(height: 20),
// ── Time visualizer (if there is actual data) ──────
if (_task != null &&
_task!.estimatedMinutes != null &&
_task!.actualMinutes != null)
TimeVisualizer(
estimatedMinutes: _task!.estimatedMinutes!,
actualMinutes: _task!.actualMinutes,
),
const SizedBox(height: 20),
// ── Tags ───────────────────────────────────────────
TextFormField(
controller: _tagsController,
decoration: const InputDecoration(
labelText: 'Tags (comma separated)',
prefixIcon: Icon(Icons.label_outline),
),
),
const SizedBox(height: 16),
// ── Category ───────────────────────────────────────
TextFormField(
controller: _categoryController,
decoration: const InputDecoration(
labelText: 'Category',
prefixIcon: Icon(Icons.category_outlined),
),
),
const SizedBox(height: 32),
// ── Save button ────────────────────────────────────
FilledButton(
onPressed: _save,
child: const Text('Save'),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,81 @@
import 'package:flutter/material.dart';
import '../../../../core/theme/app_colors.dart';
/// Three-option energy selector with icons.
///
/// Horizontal toggle: Low | Medium | High
class EnergySelector extends StatelessWidget {
final String value; // 'low', 'medium', 'high'
final ValueChanged<String> onChanged;
const EnergySelector({
super.key,
required this.value,
required this.onChanged,
});
static const _options = [
_EnergyOption(key: 'low', label: 'Low', icon: Icons.spa_outlined, color: AppColors.energyLow),
_EnergyOption(key: 'medium', label: 'Medium', icon: Icons.bolt_outlined, color: AppColors.energyMedium),
_EnergyOption(key: 'high', label: 'High', icon: Icons.local_fire_department_outlined, color: AppColors.energyHigh),
];
@override
Widget build(BuildContext context) {
return Row(
children: _options.map((opt) {
final selected = value == opt.key;
return Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: GestureDetector(
onTap: () => onChanged(opt.key),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
padding: const EdgeInsets.symmetric(vertical: 14),
decoration: BoxDecoration(
color: selected ? opt.color.withAlpha(40) : Colors.transparent,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: selected ? opt.color : Colors.grey.withAlpha(60),
width: selected ? 2 : 1,
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(opt.icon, color: selected ? opt.color : Colors.grey, size: 26),
const SizedBox(height: 4),
Text(
opt.label,
style: TextStyle(
fontSize: 13,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
color: selected ? opt.color : Colors.grey,
),
),
],
),
),
),
),
);
}).toList(),
);
}
}
class _EnergyOption {
final String key;
final String label;
final IconData icon;
final Color color;
const _EnergyOption({
required this.key,
required this.label,
required this.icon,
required this.color,
});
}

View File

@@ -0,0 +1,229 @@
import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/widgets/time_visualizer.dart';
/// Time perception tools dashboard.
///
/// - Accuracy trend chart (fl_chart) showing estimate vs actual over time.
/// - "You tend to underestimate by X%" insight.
/// - Recent time entries list.
/// - Tips for improving time awareness.
class TimeDashboardScreen extends StatelessWidget {
const TimeDashboardScreen({super.key});
// ── Dummy data for the chart ─────────────────────────────────────
static const _estimatedData = [15.0, 20.0, 10.0, 30.0, 25.0, 15.0, 20.0];
static const _actualData = [22.0, 18.0, 14.0, 40.0, 28.0, 20.0, 19.0];
static const _labels = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
double get _averageBias {
double total = 0;
for (int i = 0; i < _estimatedData.length; i++) {
total += (_actualData[i] - _estimatedData[i]) / _estimatedData[i];
}
return (total / _estimatedData.length) * 100;
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final bias = _averageBias;
final biasText = bias > 0
? 'You tend to underestimate by ${bias.abs().toStringAsFixed(0)}%'
: 'You tend to overestimate by ${bias.abs().toStringAsFixed(0)}%';
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back_rounded),
onPressed: () => context.go('/'),
),
title: const Text('Time Perception'),
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
// ── Insight card ─────────────────────────────────────────
Card(
color: AppColors.primaryLight.withAlpha(30),
elevation: 0,
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
const Icon(Icons.insights_rounded, size: 32, color: AppColors.primary),
const SizedBox(width: 12),
Expanded(
child: Text(biasText, style: theme.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w600,
)),
),
],
),
),
),
const SizedBox(height: 20),
// ── Trend chart ──────────────────────────────────────────
Text('Estimated vs Actual (this week)', style: theme.textTheme.titleMedium),
const SizedBox(height: 12),
SizedBox(
height: 220,
child: LineChart(
LineChartData(
gridData: const FlGridData(show: false),
titlesData: FlTitlesData(
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 32,
getTitlesWidget: (value, meta) => Text(
'${value.toInt()}m',
style: theme.textTheme.bodySmall,
),
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) {
final idx = value.toInt();
if (idx < 0 || idx >= _labels.length) return const SizedBox.shrink();
return Text(_labels[idx], style: theme.textTheme.bodySmall);
},
),
),
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
),
borderData: FlBorderData(show: false),
lineBarsData: [
// Estimated
LineChartBarData(
spots: List.generate(
_estimatedData.length,
(i) => FlSpot(i.toDouble(), _estimatedData[i]),
),
isCurved: true,
color: AppColors.primary,
barWidth: 3,
dotData: const FlDotData(show: true),
belowBarData: BarAreaData(
show: true,
color: AppColors.primary.withAlpha(20),
),
),
// Actual
LineChartBarData(
spots: List.generate(
_actualData.length,
(i) => FlSpot(i.toDouble(), _actualData[i]),
),
isCurved: true,
color: AppColors.secondary,
barWidth: 3,
dotData: const FlDotData(show: true),
dashArray: [6, 4],
),
],
minY: 0,
),
),
),
const SizedBox(height: 8),
// ── Legend ────────────────────────────────────────────────
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_LegendDot(color: AppColors.primary, label: 'Estimated'),
const SizedBox(width: 20),
_LegendDot(color: AppColors.secondary, label: 'Actual'),
],
),
const SizedBox(height: 24),
// ── Recent entries ───────────────────────────────────────
Text('Recent', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
...List.generate(
_estimatedData.length,
(i) => Padding(
padding: const EdgeInsets.only(bottom: 12),
child: TimeVisualizer(
estimatedMinutes: _estimatedData[i].toInt(),
actualMinutes: _actualData[i].toInt(),
),
),
),
const SizedBox(height: 24),
// ── Tips ─────────────────────────────────────────────────
Text('Tips for improving time awareness', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
const _TipCard(
icon: Icons.timer_outlined,
text: 'Before starting, say your estimate out loud — it makes you more committed.',
),
const _TipCard(
icon: Icons.visibility_rounded,
text: 'Use a visible clock or timer while working to build a sense of passing time.',
),
const _TipCard(
icon: Icons.edit_note_rounded,
text: 'After each task, note how long it really took. Patterns will emerge!',
),
],
),
);
}
}
class _LegendDot extends StatelessWidget {
final Color color;
final String label;
const _LegendDot({required this.color, required this.label});
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(width: 10, height: 10, decoration: BoxDecoration(color: color, shape: BoxShape.circle)),
const SizedBox(width: 6),
Text(label, style: Theme.of(context).textTheme.bodySmall),
],
);
}
}
class _TipCard extends StatelessWidget {
final IconData icon;
final String text;
const _TipCard({required this.icon, required this.text});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
elevation: 0,
color: AppColors.surfaceVariantLight,
margin: const EdgeInsets.only(bottom: 8),
child: Padding(
padding: const EdgeInsets.all(14),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 22, color: AppColors.primary),
const SizedBox(width: 10),
Expanded(child: Text(text, style: theme.textTheme.bodyMedium)),
],
),
),
);
}
}

View File

@@ -0,0 +1,159 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../../../../core/theme/app_colors.dart';
/// Non-anxiety-inducing timer widget.
///
/// Design rules:
/// - Circular progress (NOT countdown — progress forward).
/// - Soft pulsing animation.
/// - Color stays calm (teal/blue).
/// - No alarming sounds or red colors.
/// - Shows "X min so far" not "X min remaining."
class GentleTimer extends StatefulWidget {
/// Total elapsed seconds.
final int elapsedSeconds;
/// Optional estimated total in seconds (used only for the progress ring).
final int? estimatedTotalSeconds;
/// Widget size (width & height).
final double size;
const GentleTimer({
super.key,
required this.elapsedSeconds,
this.estimatedTotalSeconds,
this.size = 180,
});
@override
State<GentleTimer> createState() => _GentleTimerState();
}
class _GentleTimerState extends State<GentleTimer>
with SingleTickerProviderStateMixin {
late final AnimationController _pulseController;
late final Animation<double> _pulse;
@override
void initState() {
super.initState();
_pulseController = AnimationController(
vsync: this,
duration: const Duration(seconds: 2),
)..repeat(reverse: true);
_pulse = Tween<double>(begin: 0.96, end: 1.0).animate(
CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut),
);
}
@override
void dispose() {
_pulseController.dispose();
super.dispose();
}
String _formatElapsed() {
final m = widget.elapsedSeconds ~/ 60;
final s = widget.elapsedSeconds % 60;
if (m == 0) return '${s}s so far';
return '${m}m ${s.toString().padLeft(2, '0')}s so far';
}
double get _progress {
final total = widget.estimatedTotalSeconds;
if (total == null || total == 0) {
// No estimate — use a slow modular fill so the ring still moves.
return (widget.elapsedSeconds % 300) / 300;
}
return (widget.elapsedSeconds / total).clamp(0.0, 1.0);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AnimatedBuilder(
animation: _pulse,
builder: (context, child) {
return Transform.scale(
scale: _pulse.value,
child: child,
);
},
child: SizedBox(
width: widget.size,
height: widget.size,
child: CustomPaint(
painter: _GentleTimerPainter(
progress: _progress,
color: AppColors.primary,
backgroundColor: AppColors.primary.withAlpha(25),
),
child: Center(
child: Text(
_formatElapsed(),
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: AppColors.primary,
),
textAlign: TextAlign.center,
),
),
),
),
);
}
}
class _GentleTimerPainter extends CustomPainter {
final double progress;
final Color color;
final Color backgroundColor;
_GentleTimerPainter({
required this.progress,
required this.color,
required this.backgroundColor,
});
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = (size.shortestSide / 2) - 10;
const strokeWidth = 10.0;
const startAngle = -math.pi / 2;
// Background circle
canvas.drawCircle(
center,
radius,
Paint()
..color = backgroundColor
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.round,
);
// Progress arc
if (progress > 0) {
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
startAngle,
2 * math.pi * progress,
false,
Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.round,
);
}
}
@override
bool shouldRepaint(covariant _GentleTimerPainter oldDelegate) =>
progress != oldDelegate.progress;
}

16
lib/main.dart Normal file
View File

@@ -0,0 +1,16 @@
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'app.dart';
import 'core/network/api_client.dart';
final getIt = GetIt.instance;
void main() {
WidgetsFlutterBinding.ensureInitialized();
_setupDI();
runApp(const FocusFlowApp());
}
void _setupDI() {
getIt.registerLazySingleton<ApiClient>(() => ApiClient());
}

123
lib/routing/app_router.dart Normal file
View File

@@ -0,0 +1,123 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../core/network/interceptors/auth_interceptor.dart';
import '../features/auth/presentation/screens/login_screen.dart';
import '../features/auth/presentation/screens/signup_screen.dart';
import '../features/body_doubling/presentation/screens/rooms_screen.dart';
import '../features/settings/presentation/screens/settings_screen.dart';
import '../features/streaks/presentation/screens/streaks_screen.dart';
import '../features/tasks/presentation/screens/create_task_screen.dart';
import '../features/tasks/presentation/screens/focus_mode_screen.dart';
import '../features/tasks/presentation/screens/onboarding_screen.dart';
import '../features/tasks/presentation/screens/task_dashboard_screen.dart';
import '../features/tasks/presentation/screens/task_detail_screen.dart';
import '../features/time_perception/presentation/screens/time_dashboard_screen.dart';
/// Application router using GoRouter.
///
/// Auth redirect: unauthenticated users are sent to /login except
/// when they are already on /login, /signup, or /onboarding.
class AppRouter {
AppRouter._();
static final _rootNavigatorKey = GlobalKey<NavigatorState>();
static final GoRouter router = GoRouter(
navigatorKey: _rootNavigatorKey,
initialLocation: '/',
redirect: _authRedirect,
routes: [
// ── Home / Task Dashboard ────────────────────────────────────
GoRoute(
path: '/',
name: 'home',
builder: (context, state) => const TaskDashboardScreen(),
),
// ── Focus mode — "just do the next thing" ───────────────────
GoRoute(
path: '/focus',
name: 'focus',
builder: (context, state) => const FocusModeScreen(),
),
// ── Auth ─────────────────────────────────────────────────────
GoRoute(
path: '/login',
name: 'login',
builder: (context, state) => const LoginScreen(),
),
GoRoute(
path: '/signup',
name: 'signup',
builder: (context, state) => const SignupScreen(),
),
// ── Task detail ──────────────────────────────────────────────
GoRoute(
path: '/task/:id',
name: 'taskDetail',
builder: (context, state) => TaskDetailScreen(
taskId: state.pathParameters['id']!,
),
),
// ── Create task ──────────────────────────────────────────────
GoRoute(
path: '/task-create',
name: 'createTask',
builder: (context, state) => const CreateTaskScreen(),
),
// ── Streaks ──────────────────────────────────────────────────
GoRoute(
path: '/streaks',
name: 'streaks',
builder: (context, state) => const StreaksScreen(),
),
// ── Time perception ──────────────────────────────────────────
GoRoute(
path: '/time',
name: 'time',
builder: (context, state) => const TimeDashboardScreen(),
),
// ── Body doubling rooms ──────────────────────────────────────
GoRoute(
path: '/rooms',
name: 'rooms',
builder: (context, state) => const RoomsScreen(),
),
// ── Settings ─────────────────────────────────────────────────
GoRoute(
path: '/settings',
name: 'settings',
builder: (context, state) => const SettingsScreen(),
),
// ── Onboarding ───────────────────────────────────────────────
GoRoute(
path: '/onboarding',
name: 'onboarding',
builder: (context, state) => const OnboardingScreen(),
),
],
);
/// Redirect unauthenticated users to /login.
static Future<String?> _authRedirect(
BuildContext context,
GoRouterState state,
) async {
final publicPaths = {'/login', '/signup', '/onboarding'};
if (publicPaths.contains(state.matchedLocation)) return null;
final loggedIn = await AuthInterceptor.hasToken();
if (!loggedIn) return '/login';
return null; // no redirect needed
}
}

1289
pubspec.lock Normal file

File diff suppressed because it is too large Load Diff

50
pubspec.yaml Normal file
View File

@@ -0,0 +1,50 @@
name: focusflow_app
description: FocusFlow - ADHD Task & Life Management
publish_to: 'none'
version: 1.0.0+1
environment:
sdk: ^3.7.0
dependencies:
flutter:
sdk: flutter
flutter_bloc: ^9.0.0
bloc: ^9.0.0
equatable: ^2.0.7
get_it: ^8.0.0
dio: ^5.7.0
go_router: ^14.6.0
flutter_secure_storage: ^9.2.0
drift: ^2.22.0
sqlite3_flutter_libs: ^0.5.0
connectivity_plus: ^6.1.0
flutter_local_notifications: ^18.0.0
lottie: ^3.0.0
fl_chart: ^0.70.0
google_fonts: ^6.2.0
json_annotation: ^4.9.0
intl: ^0.19.0
wakelock_plus: ^1.2.0
share_plus: ^10.1.0
web_socket_channel: ^3.0.0
cached_network_image: ^3.4.0
shimmer: ^3.0.0
focusflow_shared:
path: ../focusflow_shared
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0
bloc_test: ^10.0.0
build_runner: ^2.4.0
json_serializable: ^6.8.0
drift_dev: ^2.22.0
mocktail: ^1.0.0
flutter:
uses-material-design: true
assets:
- assets/animations/
- assets/images/

9
test/widget_test.dart Normal file
View File

@@ -0,0 +1,9 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:focusflow_app/app.dart';
void main() {
testWidgets('App renders without crashing', (WidgetTester tester) async {
await tester.pumpWidget(const FocusFlowApp());
expect(find.byType(FocusFlowApp), findsOneWidget);
});
}

BIN
web/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 917 B

BIN
web/icons/Icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Some files were not shown because too many files have changed in this diff Show More