Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c279e86750 |
Binary file not shown.
@@ -4,7 +4,7 @@ android {
|
|||||||
namespace = "io.ionic.starter"
|
namespace = "io.ionic.starter"
|
||||||
compileSdk = rootProject.ext.compileSdkVersion
|
compileSdk = rootProject.ext.compileSdkVersion
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "io.ionic.starter"
|
applicationId "com.habitmode.llemba.com"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 1
|
versionCode 1
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<?xml version='1.0' encoding='utf-8'?>
|
<?xml version='1.0' encoding='utf-8'?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">PowerSlap</string>
|
<string name="app_name">app</string>
|
||||||
<string name="title_activity_main">PowerSlap</string>
|
<string name="title_activity_main">app</string>
|
||||||
<string name="package_name">io.ionic.starter</string>
|
<string name="package_name">io.ionic.starter</string>
|
||||||
<string name="custom_url_scheme">io.ionic.starter</string>
|
<string name="custom_url_scheme">io.ionic.starter</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
+3
-3
@@ -1,13 +1,13 @@
|
|||||||
import type { CapacitorConfig } from '@capacitor/cli';
|
import type { CapacitorConfig } from '@capacitor/cli';
|
||||||
|
|
||||||
const config: CapacitorConfig = {
|
const config: CapacitorConfig = {
|
||||||
appId: 'io.powerslap.app',
|
appId: 'com.habitmode.llemba.com',
|
||||||
appName: 'PowerSlap',
|
appName: 'HabitMode',
|
||||||
webDir: 'dist',
|
webDir: 'dist',
|
||||||
plugins: {
|
plugins: {
|
||||||
FirebaseAuthentication: {
|
FirebaseAuthentication: {
|
||||||
skipNativeAuth: false,
|
skipNativeAuth: false,
|
||||||
providers: [],
|
providers: ["google.com", "apple.com"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+66
-27
@@ -11,6 +11,9 @@ workflows:
|
|||||||
- $HOME/.gradle/caches
|
- $HOME/.gradle/caches
|
||||||
- $HOME/.gradle/wrapper
|
- $HOME/.gradle/wrapper
|
||||||
scripts:
|
scripts:
|
||||||
|
# Template ships with optional native plugins (maps, push, firebase auth) that most apps
|
||||||
|
# won't use. Keeping unused plugins in package.json adds SPM/Gradle deps and bloats the
|
||||||
|
# build. Strip any that have no import in src/ so only actually-used plugins are compiled.
|
||||||
- name: Strip unused scaffold packages
|
- name: Strip unused scaffold packages
|
||||||
script: |
|
script: |
|
||||||
for pkg in "@capacitor/google-maps" "@capacitor/push-notifications" "@capacitor-firebase/authentication"; do
|
for pkg in "@capacitor/google-maps" "@capacitor/push-notifications" "@capacitor-firebase/authentication"; do
|
||||||
@@ -21,10 +24,16 @@ workflows:
|
|||||||
echo "Keeping $pkg (used in src/)"
|
echo "Keeping $pkg (used in src/)"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
# Delete package-lock.json (generated if user ever ran npm locally) so it doesn't
|
||||||
|
# conflict with yarn.lock. --legacy-peer-deps silences peer dep warnings from older
|
||||||
|
# Ionic/React packages that haven't updated their peerDependencies for React 18/19.
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
script: rm -f package-lock.json && npm install --legacy-peer-deps
|
script: rm -f package-lock.json && npm install --legacy-peer-deps
|
||||||
- name: Build web assets
|
- name: Build web assets
|
||||||
script: npm run build
|
script: npm run build
|
||||||
|
# Firebase config files are injected as base64 env vars by the Appcakes backend at
|
||||||
|
# build time. Decode them here so cap sync and Gradle can pick them up. Optional —
|
||||||
|
# builds without Firebase still succeed; native Firebase SDK just won't initialise.
|
||||||
- name: Write Firebase config
|
- name: Write Firebase config
|
||||||
script: |
|
script: |
|
||||||
if [ -n "$GOOGLE_SERVICES_JSON" ]; then
|
if [ -n "$GOOGLE_SERVICES_JSON" ]; then
|
||||||
@@ -37,6 +46,8 @@ workflows:
|
|||||||
echo "$GOOGLE_SERVICE_INFO_PLIST" | base64 --decode > ios/App/App/GoogleService-Info.plist
|
echo "$GOOGLE_SERVICE_INFO_PLIST" | base64 --decode > ios/App/App/GoogleService-Info.plist
|
||||||
echo "Wrote GoogleService-Info.plist ($(wc -c < ios/App/App/GoogleService-Info.plist) bytes)"
|
echo "Wrote GoogleService-Info.plist ($(wc -c < ios/App/App/GoogleService-Info.plist) bytes)"
|
||||||
fi
|
fi
|
||||||
|
# cap sync copies the built web assets (dist/) into android/app/src/main/assets/public/
|
||||||
|
# and updates Capacitor plugin registrations in MainActivity. Must run after npm build.
|
||||||
- name: Capacitor sync
|
- name: Capacitor sync
|
||||||
script: npx cap sync android
|
script: npx cap sync android
|
||||||
- name: Set up debug keystore
|
- name: Set up debug keystore
|
||||||
@@ -44,6 +55,13 @@ workflows:
|
|||||||
if [ -n "$CM_DEBUG_KEYSTORE" ]; then
|
if [ -n "$CM_DEBUG_KEYSTORE" ]; then
|
||||||
echo "$CM_DEBUG_KEYSTORE" | base64 --decode > /tmp/debug-keystore.p12
|
echo "$CM_DEBUG_KEYSTORE" | base64 --decode > /tmp/debug-keystore.p12
|
||||||
fi
|
fi
|
||||||
|
- name: Check native credentials
|
||||||
|
script: |
|
||||||
|
if [ -n "$VITE_GOOGLE_MAPS_KEY" ]; then
|
||||||
|
echo "VITE_GOOGLE_MAPS_KEY: set (length=${#VITE_GOOGLE_MAPS_KEY}, ends …${VITE_GOOGLE_MAPS_KEY: -4})"
|
||||||
|
else
|
||||||
|
echo "WARNING: VITE_GOOGLE_MAPS_KEY not set — Google Maps will not render"
|
||||||
|
fi
|
||||||
- name: Build debug APK
|
- name: Build debug APK
|
||||||
script: |
|
script: |
|
||||||
cd android
|
cd android
|
||||||
@@ -57,6 +75,21 @@ workflows:
|
|||||||
else
|
else
|
||||||
./gradlew assembleDebug --no-daemon
|
./gradlew assembleDebug --no-daemon
|
||||||
fi
|
fi
|
||||||
|
- name: Verify manifest substitution
|
||||||
|
script: |
|
||||||
|
MANIFEST="android/app/build/intermediates/merged_manifests/debug/AndroidManifest.xml"
|
||||||
|
if [ -f "$MANIFEST" ]; then
|
||||||
|
VAL=$(grep -A1 'geo.API_KEY' "$MANIFEST" | grep -o 'android:value="[^"]*"' | cut -d'"' -f2)
|
||||||
|
if [ -z "$VAL" ]; then
|
||||||
|
echo "WARNING: geo.API_KEY meta-data not found in merged manifest"
|
||||||
|
elif [ "$VAL" = '${googleMapsApiKey}' ]; then
|
||||||
|
echo "WARNING: geo.API_KEY placeholder was not substituted — key missing from build env"
|
||||||
|
else
|
||||||
|
echo "geo.API_KEY substituted OK (ends …${VAL: -4})"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Merged manifest not found at $MANIFEST"
|
||||||
|
fi
|
||||||
artifacts:
|
artifacts:
|
||||||
- android/app/build/outputs/apk/debug/app-debug.apk
|
- android/app/build/outputs/apk/debug/app-debug.apk
|
||||||
|
|
||||||
@@ -228,26 +261,25 @@ workflows:
|
|||||||
script: rm -f package-lock.json && npm install --legacy-peer-deps
|
script: rm -f package-lock.json && npm install --legacy-peer-deps
|
||||||
- name: Build web assets
|
- name: Build web assets
|
||||||
script: npm run build
|
script: npm run build
|
||||||
|
# First sync: regenerates CapApp-SPM/Package.swift with the correct plugin list.
|
||||||
|
# Note: this project uses Capacitor SPM (no CocoaPods/Podfile), so cap sync replaces
|
||||||
|
# pod install — it regenerates CapApp-SPM/Package.swift and copies web assets.
|
||||||
- name: Capacitor sync
|
- name: Capacitor sync
|
||||||
script: npx cap sync ios
|
script: npx cap sync ios
|
||||||
|
# Decode Firebase plist to disk so it is present during the second sync.
|
||||||
- name: Write Firebase config
|
- name: Write Firebase config
|
||||||
script: |
|
script: |
|
||||||
if [ -n "$GOOGLE_SERVICE_INFO_PLIST" ]; then
|
if [ -n "$GOOGLE_SERVICE_INFO_PLIST" ]; then
|
||||||
echo "$GOOGLE_SERVICE_INFO_PLIST" | base64 --decode > ios/App/App/GoogleService-Info.plist
|
echo "$GOOGLE_SERVICE_INFO_PLIST" | base64 --decode > ios/App/App/GoogleService-Info.plist
|
||||||
ruby -e "
|
|
||||||
require 'xcodeproj'
|
|
||||||
project = Xcodeproj::Project.open('ios/App/App.xcodeproj')
|
|
||||||
target = project.targets.find { |t| t.name == 'App' }
|
|
||||||
app_group = project.main_group['App']
|
|
||||||
unless app_group.files.find { |f| f.path == 'GoogleService-Info.plist' }
|
|
||||||
file_ref = app_group.new_reference('GoogleService-Info.plist')
|
|
||||||
target.resources_build_phase.add_file_reference(file_ref)
|
|
||||||
project.save
|
|
||||||
end
|
|
||||||
"
|
|
||||||
fi
|
fi
|
||||||
|
# Second sync ensures CapApp-SPM/Package.swift is consistent after the plist write.
|
||||||
- name: Capacitor sync
|
- name: Capacitor sync
|
||||||
script: npx cap sync ios
|
script: npx cap sync ios
|
||||||
|
# Run configure_xcode.rb after both syncs so its xcodeproj changes (plist file
|
||||||
|
# reference + SPM product linking) are not overwritten by a subsequent cap sync.
|
||||||
|
# configure_xcode.rb is stamped fresh from template on every build push.
|
||||||
|
- name: Configure Xcode project
|
||||||
|
script: ruby configure_xcode.rb
|
||||||
- name: Set up signing
|
- name: Set up signing
|
||||||
script: |
|
script: |
|
||||||
keychain initialize
|
keychain initialize
|
||||||
@@ -272,11 +304,23 @@ workflows:
|
|||||||
echo "No signing credentials configured — build will fail at signing"
|
echo "No signing credentials configured — build will fail at signing"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
# --workspace: Capacitor SPM projects require workspace context for Xcode to resolve
|
||||||
|
# the CapApp-SPM local package. Using --project causes xcodebuild to fail on
|
||||||
|
# Xcode 26+ because SPM resolution requires workspace scope. App.xcworkspace is a
|
||||||
|
# static file committed to the template (not generated by cap sync) that wraps
|
||||||
|
# App.xcodeproj for this purpose.
|
||||||
|
# --no-show-build-settings: xcode-project runs xcodebuild -showBuildSettings as a
|
||||||
|
# pre-build diagnostic. Xcode 26 fails this step for SPM projects even with
|
||||||
|
# --workspace, so we skip it. The actual archive step still surfaces real errors.
|
||||||
|
# --disable-xcpretty: xcpretty reformats and filters xcodebuild output, swallowing
|
||||||
|
# the actual error lines when a build fails. Raw output makes failures debuggable.
|
||||||
- name: Build IPA
|
- name: Build IPA
|
||||||
script: |
|
script: |
|
||||||
xcode-project build-ipa \
|
xcode-project build-ipa \
|
||||||
--project ios/App/App.xcodeproj \
|
--workspace ios/App/App.xcworkspace \
|
||||||
--scheme App
|
--scheme App \
|
||||||
|
--no-show-build-settings \
|
||||||
|
--disable-xcpretty
|
||||||
artifacts:
|
artifacts:
|
||||||
- build/ios/ipa/*.ipa
|
- build/ios/ipa/*.ipa
|
||||||
|
|
||||||
@@ -304,24 +348,15 @@ workflows:
|
|||||||
script: npm run build
|
script: npm run build
|
||||||
- name: Capacitor sync
|
- name: Capacitor sync
|
||||||
script: npx cap sync ios
|
script: npx cap sync ios
|
||||||
- name: Write Firebase config
|
- name: Configure Xcode project
|
||||||
script: |
|
script: |
|
||||||
if [ -n "$GOOGLE_SERVICE_INFO_PLIST" ]; then
|
if [ -n "$GOOGLE_SERVICE_INFO_PLIST" ]; then
|
||||||
echo "$GOOGLE_SERVICE_INFO_PLIST" | base64 --decode > ios/App/App/GoogleService-Info.plist
|
echo "$GOOGLE_SERVICE_INFO_PLIST" | base64 --decode > ios/App/App/GoogleService-Info.plist
|
||||||
ruby -e "
|
|
||||||
require 'xcodeproj'
|
|
||||||
project = Xcodeproj::Project.open('ios/App/App.xcodeproj')
|
|
||||||
target = project.targets.find { |t| t.name == 'App' }
|
|
||||||
app_group = project.main_group['App']
|
|
||||||
unless app_group.files.find { |f| f.path == 'GoogleService-Info.plist' }
|
|
||||||
file_ref = app_group.new_reference('GoogleService-Info.plist')
|
|
||||||
target.resources_build_phase.add_file_reference(file_ref)
|
|
||||||
project.save
|
|
||||||
end
|
|
||||||
"
|
|
||||||
fi
|
fi
|
||||||
- name: Capacitor sync
|
- name: Capacitor sync
|
||||||
script: npx cap sync ios
|
script: npx cap sync ios
|
||||||
|
- name: Configure Xcode project
|
||||||
|
script: ruby configure_xcode.rb
|
||||||
- name: Set up signing
|
- name: Set up signing
|
||||||
script: |
|
script: |
|
||||||
keychain initialize
|
keychain initialize
|
||||||
@@ -343,12 +378,16 @@ workflows:
|
|||||||
echo "$CM_PROVISIONING_PROFILE" | base64 --decode > "$HOME/Library/MobileDevice/Provisioning Profiles/profile.mobileprovision"
|
echo "$CM_PROVISIONING_PROFILE" | base64 --decode > "$HOME/Library/MobileDevice/Provisioning Profiles/profile.mobileprovision"
|
||||||
xcode-project use-profiles
|
xcode-project use-profiles
|
||||||
fi
|
fi
|
||||||
|
# See ios-debug Build IPA comments for --workspace, --no-show-build-settings,
|
||||||
|
# and --disable-xcpretty rationale — same reasons apply here.
|
||||||
- name: Build IPA
|
- name: Build IPA
|
||||||
script: |
|
script: |
|
||||||
xcode-project build-ipa \
|
xcode-project build-ipa \
|
||||||
--project ios/App/App.xcodeproj \
|
--workspace ios/App/App.xcworkspace \
|
||||||
--scheme App \
|
--scheme App \
|
||||||
--config Release
|
--config Release \
|
||||||
|
--no-show-build-settings \
|
||||||
|
--disable-xcpretty
|
||||||
- name: Publish
|
- name: Publish
|
||||||
script: |
|
script: |
|
||||||
if [ "$SUBMIT_TO_TESTFLIGHT" = "true" ] || [ "$SUBMIT_TO_APP_STORE" = "true" ]; then
|
if [ "$SUBMIT_TO_TESTFLIGHT" = "true" ] || [ "$SUBMIT_TO_APP_STORE" = "true" ]; then
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
#!/usr/bin/env ruby
|
||||||
|
# configure_xcode.rb — stamped fresh from template on every build (see gitea.service.ts)
|
||||||
|
#
|
||||||
|
# 1. Adds GoogleService-Info.plist to the Xcode target if present (xcodeproj gem)
|
||||||
|
# 2. Scans Swift sources for Firebase imports and injects matching products into
|
||||||
|
# CapApp-SPM/Package.swift — the correct approach for Capacitor SPM projects
|
||||||
|
# where all native linking goes through CapApp-SPM, not App.xcodeproj directly.
|
||||||
|
|
||||||
|
require 'xcodeproj'
|
||||||
|
|
||||||
|
PROJECT_PATH = 'ios/App/App.xcodeproj'
|
||||||
|
TARGET_NAME = 'App'
|
||||||
|
PLIST_SRC = 'ios/App/App/GoogleService-Info.plist'
|
||||||
|
CAP_APP_SPM = 'ios/App/CapApp-SPM/Package.swift'
|
||||||
|
|
||||||
|
# ── 1. GoogleService-Info.plist ───────────────────────────────────────────────
|
||||||
|
if File.exist?(PLIST_SRC)
|
||||||
|
project = Xcodeproj::Project.open(PROJECT_PATH)
|
||||||
|
target = project.targets.find { |t| t.name == TARGET_NAME }
|
||||||
|
if target
|
||||||
|
app_group = project.main_group['App']
|
||||||
|
unless app_group&.files&.find { |f| f.path == 'GoogleService-Info.plist' }
|
||||||
|
file_ref = app_group.new_reference('GoogleService-Info.plist')
|
||||||
|
target.resources_build_phase.add_file_reference(file_ref)
|
||||||
|
project.save
|
||||||
|
puts "Added GoogleService-Info.plist to target"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── 2. Scan Swift sources for Firebase imports ────────────────────────────────
|
||||||
|
swift_files = Dir['ios/App/App/**/*.swift']
|
||||||
|
firebase_imports = swift_files
|
||||||
|
.flat_map { |f| File.readlines(f) rescue [] }
|
||||||
|
.grep(/^\s*import\s+Firebase\w+/)
|
||||||
|
.map { |l| l.strip.split(/\s+/).last }
|
||||||
|
.uniq
|
||||||
|
.sort
|
||||||
|
|
||||||
|
if firebase_imports.empty?
|
||||||
|
puts "No Firebase imports found — skipping CapApp-SPM update"
|
||||||
|
exit 0
|
||||||
|
end
|
||||||
|
|
||||||
|
puts "Firebase imports detected: #{firebase_imports.join(', ')}"
|
||||||
|
|
||||||
|
unless File.exist?(CAP_APP_SPM)
|
||||||
|
warn "WARNING: #{CAP_APP_SPM} not found — was cap sync run?"
|
||||||
|
exit 0
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── 3. Detect firebase-ios-sdk URL and version from node_modules ──────────────
|
||||||
|
# Read from the ACTUAL node_modules on this build machine (populated by npm install)
|
||||||
|
# so the version we inject matches what @capacitor-firebase/* already resolved.
|
||||||
|
firebase_url = nil
|
||||||
|
firebase_version = nil
|
||||||
|
|
||||||
|
Dir['node_modules/@capacitor-firebase/**/Package.swift',
|
||||||
|
'node_modules/@capacitor/push-notifications/**/Package.swift'].each do |f|
|
||||||
|
content = File.read(f) rescue next
|
||||||
|
url_m = content.match(/\.package\(url:\s*"(https:\/\/github\.com\/firebase\/firebase-ios-sdk[^"]*)"/)
|
||||||
|
ver_m = content.match(/firebase-ios-sdk[^)]+from:\s*"(\d+)\.(\d+)\.(\d+)"/)
|
||||||
|
next unless url_m && ver_m
|
||||||
|
|
||||||
|
firebase_url = url_m[1]
|
||||||
|
major = ver_m[1].to_i
|
||||||
|
firebase_version = "#{major}.0.0"
|
||||||
|
puts "Detected firebase-ios-sdk from #{f}: url=#{firebase_url} → using from: \"#{firebase_version}\""
|
||||||
|
break
|
||||||
|
end
|
||||||
|
|
||||||
|
# Fallback — should rarely be needed
|
||||||
|
unless firebase_url
|
||||||
|
firebase_url = 'https://github.com/firebase/firebase-ios-sdk.git'
|
||||||
|
firebase_version = '11.0.0'
|
||||||
|
puts "Could not detect firebase-ios-sdk version from node_modules — using fallback #{firebase_version}"
|
||||||
|
end
|
||||||
|
|
||||||
|
firebase_pkg = 'firebase-ios-sdk'
|
||||||
|
|
||||||
|
lines = File.read(CAP_APP_SPM).split("\n")
|
||||||
|
|
||||||
|
# ── 4. Inject firebase-ios-sdk as a direct package dependency if missing ──────
|
||||||
|
# Normalise URL check to handle both with and without .git suffix
|
||||||
|
firebase_url_base = firebase_url.sub(/\.git$/, '')
|
||||||
|
unless lines.any? { |l| l.include?(firebase_url_base) }
|
||||||
|
last_pkg_idx = lines.rindex { |l| l.include?('.package(') }
|
||||||
|
if last_pkg_idx
|
||||||
|
lines[last_pkg_idx] = lines[last_pkg_idx].rstrip
|
||||||
|
lines[last_pkg_idx] += ',' unless lines[last_pkg_idx].end_with?(',')
|
||||||
|
lines.insert(last_pkg_idx + 1,
|
||||||
|
" .package(url: \"#{firebase_url}\", from: \"#{firebase_version}\")")
|
||||||
|
puts "Added #{firebase_pkg} to CapApp-SPM package dependencies"
|
||||||
|
else
|
||||||
|
warn "WARNING: no .package( line found in #{CAP_APP_SPM} — cannot inject dependency"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── 5. Inject each detected product into the target dependencies if missing ───
|
||||||
|
firebase_imports.each do |product|
|
||||||
|
next if product == 'Firebase' # umbrella import — not a linkable SPM product
|
||||||
|
|
||||||
|
product_str = ".product(name: \"#{product}\", package: \"#{firebase_pkg}\")"
|
||||||
|
next if lines.any? { |l| l.include?(product_str) }
|
||||||
|
|
||||||
|
last_prod_idx = lines.rindex { |l| l.include?('.product(') }
|
||||||
|
if last_prod_idx
|
||||||
|
lines[last_prod_idx] = lines[last_prod_idx].rstrip
|
||||||
|
lines[last_prod_idx] += ',' unless lines[last_prod_idx].end_with?(',')
|
||||||
|
lines.insert(last_prod_idx + 1, " #{product_str}")
|
||||||
|
puts "Added #{product} to CapApp-SPM target dependencies"
|
||||||
|
else
|
||||||
|
warn "WARNING: no .product( line found in #{CAP_APP_SPM} — cannot inject #{product}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
File.write(CAP_APP_SPM, lines.join("\n"))
|
||||||
|
puts "CapApp-SPM/Package.swift updated"
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||||
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
1A4EB3C21FED79650016851F /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = "<group>"; };
|
||||||
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
|
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
|
||||||
958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; };
|
958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
@@ -68,6 +69,7 @@
|
|||||||
504EC30E1FED79650016851F /* Assets.xcassets */,
|
504EC30E1FED79650016851F /* Assets.xcassets */,
|
||||||
504EC3101FED79650016851F /* LaunchScreen.storyboard */,
|
504EC3101FED79650016851F /* LaunchScreen.storyboard */,
|
||||||
504EC3131FED79650016851F /* Info.plist */,
|
504EC3131FED79650016851F /* Info.plist */,
|
||||||
|
1A4EB3C21FED79650016851F /* App.entitlements */,
|
||||||
2FAD9762203C412B000D30F8 /* config.xml */,
|
2FAD9762203C412B000D30F8 /* config.xml */,
|
||||||
50B271D01FEDC1A000F3C39B /* public */,
|
50B271D01FEDC1A000F3C39B /* public */,
|
||||||
);
|
);
|
||||||
@@ -295,6 +297,7 @@
|
|||||||
baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */;
|
baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
INFOPLIST_FILE = App/Info.plist;
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
@@ -305,7 +308,7 @@
|
|||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = io.powerslap.app;
|
PRODUCT_BUNDLE_IDENTIFIER = com.habitmode.llemba.com;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
@@ -317,6 +320,7 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
INFOPLIST_FILE = App/Info.plist;
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
@@ -326,7 +330,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = io.powerslap.app;
|
PRODUCT_BUNDLE_IDENTIFIER = com.habitmode.llemba.com;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "group:App.xcodeproj">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
@@ -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>
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
<?xml version="1.0" encoding="UTF-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">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>com.apple.developer.applesignin</key>
|
||||||
|
<array>
|
||||||
|
<string>Default</string>
|
||||||
|
</array>
|
||||||
|
<key>aps-environment</key>
|
||||||
|
<string>production</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
import Capacitor
|
import Capacitor
|
||||||
|
import FirebaseCore
|
||||||
|
import FirebaseMessaging
|
||||||
|
|
||||||
@UIApplicationMain
|
@UIApplicationMain
|
||||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||||
@@ -7,43 +9,40 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||||||
var window: UIWindow?
|
var window: UIWindow?
|
||||||
|
|
||||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||||
// Override point for customization after application launch.
|
FirebaseApp.configure()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func applicationWillResignActive(_ application: UIApplication) {
|
func applicationWillResignActive(_ application: UIApplication) {}
|
||||||
// Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
|
|
||||||
// Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game.
|
|
||||||
}
|
|
||||||
|
|
||||||
func applicationDidEnterBackground(_ application: UIApplication) {
|
func applicationDidEnterBackground(_ application: UIApplication) {}
|
||||||
// Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later.
|
|
||||||
// If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits.
|
|
||||||
}
|
|
||||||
|
|
||||||
func applicationWillEnterForeground(_ application: UIApplication) {
|
func applicationWillEnterForeground(_ application: UIApplication) {}
|
||||||
// Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background.
|
|
||||||
}
|
|
||||||
|
|
||||||
func applicationDidBecomeActive(_ application: UIApplication) {
|
func applicationDidBecomeActive(_ application: UIApplication) {}
|
||||||
// Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
|
|
||||||
}
|
|
||||||
|
|
||||||
func applicationWillTerminate(_ application: UIApplication) {
|
func applicationWillTerminate(_ application: UIApplication) {}
|
||||||
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
|
|
||||||
}
|
|
||||||
|
|
||||||
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
|
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
|
||||||
// Called when the app was launched with a url. Feel free to add additional processing here,
|
|
||||||
// but if you want the App API to support tracking app url opens, make sure to keep this call
|
|
||||||
return ApplicationDelegateProxy.shared.application(app, open: url, options: options)
|
return ApplicationDelegateProxy.shared.application(app, open: url, options: options)
|
||||||
}
|
}
|
||||||
|
|
||||||
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
|
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
|
||||||
// Called when the app was launched with an activity, including Universal Links.
|
|
||||||
// Feel free to add additional processing here, but if you want the App API to support
|
|
||||||
// tracking app url opens, make sure to keep this call
|
|
||||||
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
|
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
|
||||||
|
Messaging.messaging().apnsToken = deviceToken
|
||||||
|
Messaging.messaging().token { token, error in
|
||||||
|
if let error = error {
|
||||||
|
NotificationCenter.default.post(name: .capacitorDidFailToRegisterForRemoteNotifications, object: error)
|
||||||
|
} else if let token = token {
|
||||||
|
NotificationCenter.default.post(name: .capacitorDidRegisterForRemoteNotifications, object: token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
|
||||||
|
NotificationCenter.default.post(name: .capacitorDidFailToRegisterForRemoteNotifications, object: error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>en</string>
|
<string>en</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>PowerSlap</string>
|
<string>HabitMode</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
@@ -50,11 +50,9 @@
|
|||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
<array>
|
<array>
|
||||||
<dict>
|
<dict>
|
||||||
<key>CFBundleURLName</key>
|
|
||||||
<string>io.powerslap.app</string>
|
|
||||||
<key>CFBundleURLSchemes</key>
|
<key>CFBundleURLSchemes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>io.powerslap.app</string>
|
<string>com.googleusercontent.apps.1097913965629-pf48952i0uv9shgvie3dq11s18sd0t2p</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
|
|||||||
Generated
+3404
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -10,7 +10,7 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor-firebase/authentication": "^7.0.0",
|
"@capacitor-firebase/authentication": "^8.0.0",
|
||||||
"@capacitor/android": "8.3.4",
|
"@capacitor/android": "8.3.4",
|
||||||
"@capacitor/app": "8.1.0",
|
"@capacitor/app": "8.1.0",
|
||||||
"@capacitor/core": "8.3.4",
|
"@capacitor/core": "8.3.4",
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
"@capacitor/keyboard": "8.0.3",
|
"@capacitor/keyboard": "8.0.3",
|
||||||
"@capacitor/push-notifications": "^8.1.1",
|
"@capacitor/push-notifications": "^8.1.1",
|
||||||
"@capacitor/status-bar": "8.0.2",
|
"@capacitor/status-bar": "8.0.2",
|
||||||
"@ionic/pwa-elements": "^3.0.0",
|
"@ionic/pwa-elements": "^3.4.0",
|
||||||
"@ionic/react": "^8.5.0",
|
"@ionic/react": "^8.5.0",
|
||||||
"@ionic/react-router": "^8.5.0",
|
"@ionic/react-router": "^8.5.0",
|
||||||
"@supabase/supabase-js": "^2.0.0",
|
"@supabase/supabase-js": "^2.0.0",
|
||||||
|
|||||||
+27
-19
@@ -1,29 +1,37 @@
|
|||||||
import React from "react";
|
import React from 'react';
|
||||||
import { Redirect, Route } from "react-router-dom";
|
import { Redirect, Route } from 'react-router-dom';
|
||||||
import { IonApp, IonRouterOutlet } from "@ionic/react";
|
import { IonApp, IonRouterOutlet } from '@ionic/react';
|
||||||
import { IonReactRouter } from "@ionic/react-router";
|
import { IonReactRouter } from '@ionic/react-router';
|
||||||
|
|
||||||
import "@ionic/react/css/core.css";
|
import '@ionic/react/css/core.css';
|
||||||
import "@ionic/react/css/normalize.css";
|
import '@ionic/react/css/normalize.css';
|
||||||
import "@ionic/react/css/structure.css";
|
import '@ionic/react/css/structure.css';
|
||||||
import "@ionic/react/css/typography.css";
|
import '@ionic/react/css/typography.css';
|
||||||
import "@ionic/react/css/padding.css";
|
import '@ionic/react/css/padding.css';
|
||||||
import "@ionic/react/css/float-elements.css";
|
import '@ionic/react/css/float-elements.css';
|
||||||
import "@ionic/react/css/text-alignment.css";
|
import '@ionic/react/css/text-alignment.css';
|
||||||
import "@ionic/react/css/text-transformation.css";
|
import '@ionic/react/css/text-transformation.css';
|
||||||
import "@ionic/react/css/flex-utils.css";
|
import '@ionic/react/css/flex-utils.css';
|
||||||
import "@ionic/react/css/display.css";
|
import '@ionic/react/css/display.css';
|
||||||
|
|
||||||
import "./theme/variables.css";
|
import './theme/variables.css';
|
||||||
|
|
||||||
import Home from "./pages/Home";
|
import Home from './pages/Home';
|
||||||
|
import Account from './pages/Account';
|
||||||
|
import Auth from './pages/Auth';
|
||||||
|
import Stats from './pages/Stats';
|
||||||
|
import HabitDetail from './pages/HabitDetail';
|
||||||
|
|
||||||
const App: React.FC = () => (
|
const App = (): any => (
|
||||||
<IonApp>
|
<IonApp>
|
||||||
<IonReactRouter>
|
<IonReactRouter>
|
||||||
<IonRouterOutlet>
|
<IonRouterOutlet>
|
||||||
<Route path="/home" component={Home} exact />
|
<Route exact path="/auth" component={Auth} />
|
||||||
<Redirect exact from="/" to="/home" />
|
<Route exact path="/home" component={Home} />
|
||||||
|
<Route exact path="/stats" component={Stats} />
|
||||||
|
<Route exact path="/habit/:id" component={HabitDetail} />
|
||||||
|
<Route exact path="/account" component={Account} />
|
||||||
|
<Route exact path="/" render={() => <Redirect to="/home" />} />
|
||||||
</IonRouterOutlet>
|
</IonRouterOutlet>
|
||||||
</IonReactRouter>
|
</IonReactRouter>
|
||||||
</IonApp>
|
</IonApp>
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 138 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 152 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 771 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 752 KiB |
@@ -0,0 +1,77 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { IonProgressBar } from '@ionic/react';
|
||||||
|
|
||||||
|
interface CustomProgressBarProps {
|
||||||
|
value: number; // 0 to 1
|
||||||
|
label?: string;
|
||||||
|
statusText?: string;
|
||||||
|
color?: string; // Hex, rgb, or css variable
|
||||||
|
showPercent?: boolean;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CustomProgressBar: React.FC<CustomProgressBarProps> = ({
|
||||||
|
value,
|
||||||
|
label,
|
||||||
|
statusText,
|
||||||
|
color = 'var(--ion-color-primary)',
|
||||||
|
showPercent = true,
|
||||||
|
style,
|
||||||
|
}) => {
|
||||||
|
const percentage = Math.round(Math.min(Math.max(value, 0), 1) * 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ width: '100%', ...style }}>
|
||||||
|
{/* Label and Status */}
|
||||||
|
{(label || statusText || showPercent) && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'baseline',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
marginBottom: '6px',
|
||||||
|
fontWeight: '600',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: 'var(--ion-color-medium)' }}>
|
||||||
|
{label}{' '}
|
||||||
|
{statusText && (
|
||||||
|
<span style={{ fontWeight: 'normal', opacity: 0.85 }}>
|
||||||
|
• {statusText}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{showPercent && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: color,
|
||||||
|
fontVariantNumeric: 'tabular-nums',
|
||||||
|
fontFamily: 'ui-monospace, monospace',
|
||||||
|
fontWeight: '700',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{percentage}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Progress Track */}
|
||||||
|
<IonProgressBar
|
||||||
|
value={value}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
height: '6px',
|
||||||
|
'--background': 'rgba(148, 163, 184, 0.1)',
|
||||||
|
'--progress-background': color,
|
||||||
|
borderRadius: '10px',
|
||||||
|
transition: 'value 0.3s ease',
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CustomProgressBar;
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { IonButton, IonIcon } from '@ionic/react';
|
||||||
|
import { sparklesOutline } from 'ionicons/icons';
|
||||||
|
|
||||||
|
interface EmptyStateProps {
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
actionLabel: string;
|
||||||
|
onAction: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EmptyState: React.FC<EmptyStateProps> = ({
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
actionLabel,
|
||||||
|
onAction,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="empty-state-card home-empty-state-card">
|
||||||
|
<div className="empty-state-icon">
|
||||||
|
<IonIcon icon={sparklesOutline} />
|
||||||
|
</div>
|
||||||
|
<h2>{title}</h2>
|
||||||
|
<p>{message}</p>
|
||||||
|
<IonButton className="home-empty-state-button" onClick={onAction}>
|
||||||
|
{actionLabel}
|
||||||
|
</IonButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EmptyState;
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { IonButton, IonIcon, IonSpinner } from '@ionic/react';
|
||||||
|
import {
|
||||||
|
checkmarkOutline,
|
||||||
|
ellipseOutline,
|
||||||
|
flameOutline,
|
||||||
|
trashOutline,
|
||||||
|
} from 'ionicons/icons';
|
||||||
|
import { useHistory } from 'react-router-dom';
|
||||||
|
import type { Tables } from '../database.types';
|
||||||
|
|
||||||
|
interface HabitCardProps {
|
||||||
|
habit: Tables<'habits'>;
|
||||||
|
completedToday: boolean;
|
||||||
|
currentStreak: number;
|
||||||
|
completionRate: number;
|
||||||
|
onToggle: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
busy: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HabitCard: React.FC<HabitCardProps> = ({
|
||||||
|
habit,
|
||||||
|
completedToday,
|
||||||
|
currentStreak,
|
||||||
|
completionRate,
|
||||||
|
onToggle,
|
||||||
|
onDelete,
|
||||||
|
busy,
|
||||||
|
}) => {
|
||||||
|
const history = useHistory();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`habit-card habit-card--editorial ${completedToday ? 'is-complete' : ''}`}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => history.push(`/habit/${habit.id}`)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
history.push(`/habit/${habit.id}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-label={`View details for ${habit.name}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`habit-check-button ${completedToday ? 'is-complete' : ''}`}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onToggle();
|
||||||
|
}}
|
||||||
|
disabled={busy}
|
||||||
|
aria-label={
|
||||||
|
completedToday
|
||||||
|
? `Mark ${habit.name} as not done`
|
||||||
|
: `Mark ${habit.name} as done`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{busy ? (
|
||||||
|
<IonSpinner name="crescent" />
|
||||||
|
) : completedToday ? (
|
||||||
|
<IonIcon icon={checkmarkOutline} />
|
||||||
|
) : (
|
||||||
|
<IonIcon icon={ellipseOutline} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="habit-card-main">
|
||||||
|
<div className="habit-copy">
|
||||||
|
<div className="habit-copy-topline">
|
||||||
|
<h3>{habit.name}</h3>
|
||||||
|
<span
|
||||||
|
className="habit-color-pill"
|
||||||
|
style={{ background: habit.color }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
{habit.target_description ||
|
||||||
|
'Show up today and keep the chain alive.'}
|
||||||
|
</p>
|
||||||
|
<div className="habit-metrics-row">
|
||||||
|
<span>
|
||||||
|
<IonIcon icon={flameOutline} /> {currentStreak} streak
|
||||||
|
</span>
|
||||||
|
<span>{completionRate}% this week</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<IonButton
|
||||||
|
className="habit-delete-button"
|
||||||
|
fill="clear"
|
||||||
|
color="medium"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onDelete();
|
||||||
|
}}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
<IonIcon icon={trashOutline} />
|
||||||
|
</IonButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HabitCard;
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
IonButton,
|
||||||
|
IonButtons,
|
||||||
|
IonContent,
|
||||||
|
IonHeader,
|
||||||
|
IonInput,
|
||||||
|
IonItem,
|
||||||
|
IonLabel,
|
||||||
|
IonModal,
|
||||||
|
IonTextarea,
|
||||||
|
IonTitle,
|
||||||
|
IonToolbar,
|
||||||
|
} from '@ionic/react';
|
||||||
|
|
||||||
|
interface HabitFormModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onDismiss: () => void;
|
||||||
|
onSubmit: (values: {
|
||||||
|
name: string;
|
||||||
|
targetDescription: string;
|
||||||
|
color: string;
|
||||||
|
}) => Promise<void>;
|
||||||
|
initialValues?: {
|
||||||
|
name: string;
|
||||||
|
targetDescription: string;
|
||||||
|
color: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLORS = [
|
||||||
|
'#7c3aed',
|
||||||
|
'#0f766e',
|
||||||
|
'#ea580c',
|
||||||
|
'#dc2626',
|
||||||
|
'#2563eb',
|
||||||
|
'#059669',
|
||||||
|
];
|
||||||
|
|
||||||
|
const HabitFormModal: React.FC<HabitFormModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onDismiss,
|
||||||
|
onSubmit,
|
||||||
|
initialValues,
|
||||||
|
}) => {
|
||||||
|
const isEditing = !!initialValues;
|
||||||
|
const [name, setName] = useState(initialValues?.name ?? '');
|
||||||
|
const [targetDescription, setTargetDescription] = useState(
|
||||||
|
initialValues?.targetDescription ?? ''
|
||||||
|
);
|
||||||
|
const [color, setColor] = useState(initialValues?.color ?? COLORS[0]);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const canSubmit = useMemo(
|
||||||
|
() => name.trim().length > 0 && name.trim().length <= 80,
|
||||||
|
[name]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setName(initialValues?.name ?? '');
|
||||||
|
setTargetDescription(initialValues?.targetDescription ?? '');
|
||||||
|
setColor(initialValues?.color ?? COLORS[0]);
|
||||||
|
setError('');
|
||||||
|
}
|
||||||
|
}, [isOpen, initialValues]);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
setError('');
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await onSubmit({
|
||||||
|
name: name.trim(),
|
||||||
|
targetDescription: targetDescription.trim(),
|
||||||
|
color,
|
||||||
|
});
|
||||||
|
setName('');
|
||||||
|
setTargetDescription('');
|
||||||
|
setColor(COLORS[0]);
|
||||||
|
onDismiss();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Unable to save habit.');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IonModal isOpen={isOpen} onDidDismiss={onDismiss}>
|
||||||
|
<IonHeader className="ion-no-border">
|
||||||
|
<IonToolbar>
|
||||||
|
<IonTitle>{isEditing ? 'Edit Habit' : 'New Habit'}</IonTitle>
|
||||||
|
<IonButtons slot="end">
|
||||||
|
<IonButton onClick={onDismiss}>Close</IonButton>
|
||||||
|
</IonButtons>
|
||||||
|
</IonToolbar>
|
||||||
|
</IonHeader>
|
||||||
|
<IonContent className="ion-padding">
|
||||||
|
<div className="habit-form-grid">
|
||||||
|
<IonItem lines="none" className="auth-input-item">
|
||||||
|
<IonInput
|
||||||
|
label="Habit name"
|
||||||
|
labelPlacement="stacked"
|
||||||
|
value={name}
|
||||||
|
maxlength={80}
|
||||||
|
placeholder="Drink water"
|
||||||
|
onIonInput={(e) => setName(e.detail.value ?? '')}
|
||||||
|
/>
|
||||||
|
</IonItem>
|
||||||
|
|
||||||
|
<IonItem lines="none" className="auth-input-item">
|
||||||
|
<IonTextarea
|
||||||
|
label="Target or note"
|
||||||
|
labelPlacement="stacked"
|
||||||
|
value={targetDescription}
|
||||||
|
placeholder="8 glasses, 20 minutes, 30 pushups..."
|
||||||
|
autoGrow
|
||||||
|
onIonInput={(e) => setTargetDescription(e.detail.value ?? '')}
|
||||||
|
/>
|
||||||
|
</IonItem>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<IonLabel className="color-label">Color</IonLabel>
|
||||||
|
<div className="habit-color-row">
|
||||||
|
{COLORS.map((swatch) => (
|
||||||
|
<button
|
||||||
|
key={swatch}
|
||||||
|
type="button"
|
||||||
|
className={`habit-color-dot ${color === swatch ? 'selected' : ''}`}
|
||||||
|
style={{ background: swatch }}
|
||||||
|
onClick={() => setColor(swatch)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? <p className="auth-feedback error-text">{error}</p> : null}
|
||||||
|
|
||||||
|
<IonButton
|
||||||
|
expand="block"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!canSubmit || saving}
|
||||||
|
>
|
||||||
|
{saving ? 'Saving…' : isEditing ? 'Save Changes' : 'Create Habit'}
|
||||||
|
</IonButton>
|
||||||
|
</div>
|
||||||
|
</IonContent>
|
||||||
|
</IonModal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HabitFormModal;
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
IonHeader,
|
||||||
|
IonToolbar,
|
||||||
|
IonTitle,
|
||||||
|
IonButtons,
|
||||||
|
IonBackButton,
|
||||||
|
useIonViewWillEnter,
|
||||||
|
} from '@ionic/react';
|
||||||
|
import { setStatusBarStyle, Style } from '../utils/statusBar';
|
||||||
|
|
||||||
|
interface PageHeaderProps {
|
||||||
|
title: string;
|
||||||
|
showBackButton?: boolean;
|
||||||
|
defaultBackHref?: string;
|
||||||
|
endActions?: React.ReactNode;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
toolbarStyle?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PageHeader: React.FC<PageHeaderProps> = ({
|
||||||
|
title,
|
||||||
|
showBackButton = false,
|
||||||
|
defaultBackHref = '/home',
|
||||||
|
endActions,
|
||||||
|
style,
|
||||||
|
toolbarStyle,
|
||||||
|
}) => {
|
||||||
|
// Use Ionic lifecycle hook to set status bar style automatically for pages using PageHeader
|
||||||
|
useIonViewWillEnter(() => {
|
||||||
|
setStatusBarStyle(Style.Dark); // Dark text/icons for light-colored headers
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IonHeader className="ion-no-border" style={style}>
|
||||||
|
<IonToolbar
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
'--background': '#ffffff',
|
||||||
|
'--color': 'var(--ion-color-dark)',
|
||||||
|
'--border-width': '0 0 1px 0',
|
||||||
|
'--border-color': 'rgba(0, 0, 0, 0.05)',
|
||||||
|
...toolbarStyle,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{showBackButton && (
|
||||||
|
<IonButtons slot="start">
|
||||||
|
<IonBackButton defaultHref={defaultBackHref} />
|
||||||
|
</IonButtons>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<IonTitle
|
||||||
|
style={{
|
||||||
|
fontWeight: '800',
|
||||||
|
fontSize: '1.1rem',
|
||||||
|
letterSpacing: '-0.3px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</IonTitle>
|
||||||
|
|
||||||
|
{endActions && <IonButtons slot="end">{endActions}</IonButtons>}
|
||||||
|
</IonToolbar>
|
||||||
|
</IonHeader>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PageHeader;
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { IonIcon } from '@ionic/react';
|
||||||
|
|
||||||
|
interface RevisionInstrumentCardProps {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
icon: string;
|
||||||
|
iconColor: string;
|
||||||
|
iconBgColor: string;
|
||||||
|
onClick: () => void;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RevisionInstrumentCard: React.FC<RevisionInstrumentCardProps> = ({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
icon,
|
||||||
|
iconColor,
|
||||||
|
iconBgColor,
|
||||||
|
onClick,
|
||||||
|
style,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
background: '#ffffff',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '14px',
|
||||||
|
padding: '14px 10px',
|
||||||
|
textAlign: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
boxShadow: 'none',
|
||||||
|
transition: 'transform 0.15s ease',
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
className="ion-activatable"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '36px',
|
||||||
|
height: '36px',
|
||||||
|
borderRadius: '10px',
|
||||||
|
background: iconBgColor,
|
||||||
|
color: iconColor,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
margin: '0 auto 8px auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IonIcon icon={icon} style={{ fontSize: '18px' }} />
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
fontWeight: '700',
|
||||||
|
color: 'var(--ion-color-dark)',
|
||||||
|
display: 'block',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
{subtitle && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '0.65rem',
|
||||||
|
color: 'var(--ion-color-medium)',
|
||||||
|
display: 'block',
|
||||||
|
marginTop: '2px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{subtitle}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RevisionInstrumentCard;
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { IonProgressBar } from '@ionic/react';
|
||||||
|
|
||||||
|
interface StatsHabitCardProps {
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
completedCount: number;
|
||||||
|
totalDays: number;
|
||||||
|
streak: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StatsHabitCard: React.FC<StatsHabitCardProps> = ({
|
||||||
|
name,
|
||||||
|
color,
|
||||||
|
completedCount,
|
||||||
|
totalDays,
|
||||||
|
streak,
|
||||||
|
}) => {
|
||||||
|
const ratio = totalDays > 0 ? completedCount / totalDays : 0;
|
||||||
|
const percentage = Math.round(ratio * 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="stats-card">
|
||||||
|
<div className="stats-card-header">
|
||||||
|
<div>
|
||||||
|
<h3>{name}</h3>
|
||||||
|
<p>
|
||||||
|
{completedCount} of {totalDays} days completed
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="stats-streak-badge" style={{ color }}>
|
||||||
|
{streak}🔥
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<IonProgressBar
|
||||||
|
value={ratio}
|
||||||
|
style={{ '--progress-background': color } as React.CSSProperties}
|
||||||
|
/>
|
||||||
|
<div className="stats-card-footer">
|
||||||
|
<span>{percentage}% consistency</span>
|
||||||
|
<span>Current streak {streak}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StatsHabitCard;
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
export type Json =
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| boolean
|
||||||
|
| null
|
||||||
|
| { [key: string]: Json | undefined }
|
||||||
|
| Json[]
|
||||||
|
|
||||||
|
export type Database = {
|
||||||
|
// Allows to automatically instantiate createClient with right options
|
||||||
|
// instead of createClient<Database, { PostgrestVersion: 'XX' }>(URL, KEY)
|
||||||
|
__InternalSupabase: {
|
||||||
|
PostgrestVersion: "14.5"
|
||||||
|
}
|
||||||
|
public: {
|
||||||
|
Tables: {
|
||||||
|
habit_completions: {
|
||||||
|
Row: {
|
||||||
|
completed_on: string
|
||||||
|
created_at: string
|
||||||
|
habit_id: string
|
||||||
|
id: string
|
||||||
|
user_id: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
completed_on: string
|
||||||
|
created_at?: string
|
||||||
|
habit_id: string
|
||||||
|
id?: string
|
||||||
|
user_id: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
completed_on?: string
|
||||||
|
created_at?: string
|
||||||
|
habit_id?: string
|
||||||
|
id?: string
|
||||||
|
user_id?: string
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "habit_completions_habit_id_fkey"
|
||||||
|
columns: ["habit_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "habits"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
habits: {
|
||||||
|
Row: {
|
||||||
|
color: string
|
||||||
|
created_at: string
|
||||||
|
fcm_token: string | null
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
target_description: string | null
|
||||||
|
user_id: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
color?: string
|
||||||
|
created_at?: string
|
||||||
|
fcm_token?: string | null
|
||||||
|
id?: string
|
||||||
|
name: string
|
||||||
|
target_description?: string | null
|
||||||
|
user_id: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
color?: string
|
||||||
|
created_at?: string
|
||||||
|
fcm_token?: string | null
|
||||||
|
id?: string
|
||||||
|
name?: string
|
||||||
|
target_description?: string | null
|
||||||
|
user_id?: string
|
||||||
|
}
|
||||||
|
Relationships: []
|
||||||
|
}
|
||||||
|
notes: {
|
||||||
|
Row: {
|
||||||
|
content: string
|
||||||
|
created_at: string
|
||||||
|
id: string
|
||||||
|
is_favorite: boolean
|
||||||
|
title: string
|
||||||
|
user_id: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
content: string
|
||||||
|
created_at?: string
|
||||||
|
id?: string
|
||||||
|
is_favorite?: boolean
|
||||||
|
title: string
|
||||||
|
user_id?: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
content?: string
|
||||||
|
created_at?: string
|
||||||
|
id?: string
|
||||||
|
is_favorite?: boolean
|
||||||
|
title?: string
|
||||||
|
user_id?: string
|
||||||
|
}
|
||||||
|
Relationships: []
|
||||||
|
}
|
||||||
|
todos: {
|
||||||
|
Row: {
|
||||||
|
created_at: string
|
||||||
|
description: string | null
|
||||||
|
due_date: string | null
|
||||||
|
id: string
|
||||||
|
is_completed: boolean
|
||||||
|
title: string
|
||||||
|
user_id: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
created_at?: string
|
||||||
|
description?: string | null
|
||||||
|
due_date?: string | null
|
||||||
|
id?: string
|
||||||
|
is_completed?: boolean
|
||||||
|
title: string
|
||||||
|
user_id?: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
created_at?: string
|
||||||
|
description?: string | null
|
||||||
|
due_date?: string | null
|
||||||
|
id?: string
|
||||||
|
is_completed?: boolean
|
||||||
|
title?: string
|
||||||
|
user_id?: string
|
||||||
|
}
|
||||||
|
Relationships: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Views: {
|
||||||
|
[_ in never]: never
|
||||||
|
}
|
||||||
|
Functions: {
|
||||||
|
[_ in never]: never
|
||||||
|
}
|
||||||
|
Enums: {
|
||||||
|
[_ in never]: never
|
||||||
|
}
|
||||||
|
CompositeTypes: {
|
||||||
|
[_ in never]: never
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type DatabaseWithoutInternals = Omit<Database, "__InternalSupabase">
|
||||||
|
|
||||||
|
type DefaultSchema = DatabaseWithoutInternals[Extract<keyof Database, "public">]
|
||||||
|
|
||||||
|
export type Tables<
|
||||||
|
DefaultSchemaTableNameOrOptions extends
|
||||||
|
| keyof (DefaultSchema["Tables"] & DefaultSchema["Views"])
|
||||||
|
| { schema: keyof DatabaseWithoutInternals },
|
||||||
|
TableName extends DefaultSchemaTableNameOrOptions extends {
|
||||||
|
schema: keyof DatabaseWithoutInternals
|
||||||
|
}
|
||||||
|
? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
|
||||||
|
DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])
|
||||||
|
: never = never,
|
||||||
|
> = DefaultSchemaTableNameOrOptions extends {
|
||||||
|
schema: keyof DatabaseWithoutInternals
|
||||||
|
}
|
||||||
|
? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
|
||||||
|
DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends {
|
||||||
|
Row: infer R
|
||||||
|
}
|
||||||
|
? R
|
||||||
|
: never
|
||||||
|
: DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] &
|
||||||
|
DefaultSchema["Views"])
|
||||||
|
? (DefaultSchema["Tables"] &
|
||||||
|
DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends {
|
||||||
|
Row: infer R
|
||||||
|
}
|
||||||
|
? R
|
||||||
|
: never
|
||||||
|
: never
|
||||||
|
|
||||||
|
export type TablesInsert<
|
||||||
|
DefaultSchemaTableNameOrOptions extends
|
||||||
|
| keyof DefaultSchema["Tables"]
|
||||||
|
| { schema: keyof DatabaseWithoutInternals },
|
||||||
|
TableName extends DefaultSchemaTableNameOrOptions extends {
|
||||||
|
schema: keyof DatabaseWithoutInternals
|
||||||
|
}
|
||||||
|
? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
|
||||||
|
: never = never,
|
||||||
|
> = DefaultSchemaTableNameOrOptions extends {
|
||||||
|
schema: keyof DatabaseWithoutInternals
|
||||||
|
}
|
||||||
|
? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
|
||||||
|
Insert: infer I
|
||||||
|
}
|
||||||
|
? I
|
||||||
|
: never
|
||||||
|
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
|
||||||
|
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
|
||||||
|
Insert: infer I
|
||||||
|
}
|
||||||
|
? I
|
||||||
|
: never
|
||||||
|
: never
|
||||||
|
|
||||||
|
export type TablesUpdate<
|
||||||
|
DefaultSchemaTableNameOrOptions extends
|
||||||
|
| keyof DefaultSchema["Tables"]
|
||||||
|
| { schema: keyof DatabaseWithoutInternals },
|
||||||
|
TableName extends DefaultSchemaTableNameOrOptions extends {
|
||||||
|
schema: keyof DatabaseWithoutInternals
|
||||||
|
}
|
||||||
|
? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
|
||||||
|
: never = never,
|
||||||
|
> = DefaultSchemaTableNameOrOptions extends {
|
||||||
|
schema: keyof DatabaseWithoutInternals
|
||||||
|
}
|
||||||
|
? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
|
||||||
|
Update: infer U
|
||||||
|
}
|
||||||
|
? U
|
||||||
|
: never
|
||||||
|
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
|
||||||
|
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
|
||||||
|
Update: infer U
|
||||||
|
}
|
||||||
|
? U
|
||||||
|
: never
|
||||||
|
: never
|
||||||
|
|
||||||
|
export type Enums<
|
||||||
|
DefaultSchemaEnumNameOrOptions extends
|
||||||
|
| keyof DefaultSchema["Enums"]
|
||||||
|
| { schema: keyof DatabaseWithoutInternals },
|
||||||
|
EnumName extends DefaultSchemaEnumNameOrOptions extends {
|
||||||
|
schema: keyof DatabaseWithoutInternals
|
||||||
|
}
|
||||||
|
? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"]
|
||||||
|
: never = never,
|
||||||
|
> = DefaultSchemaEnumNameOrOptions extends {
|
||||||
|
schema: keyof DatabaseWithoutInternals
|
||||||
|
}
|
||||||
|
? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName]
|
||||||
|
: DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"]
|
||||||
|
? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions]
|
||||||
|
: never
|
||||||
|
|
||||||
|
export type CompositeTypes<
|
||||||
|
PublicCompositeTypeNameOrOptions extends
|
||||||
|
| keyof DefaultSchema["CompositeTypes"]
|
||||||
|
| { schema: keyof DatabaseWithoutInternals },
|
||||||
|
CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
|
||||||
|
schema: keyof DatabaseWithoutInternals
|
||||||
|
}
|
||||||
|
? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"]
|
||||||
|
: never = never,
|
||||||
|
> = PublicCompositeTypeNameOrOptions extends {
|
||||||
|
schema: keyof DatabaseWithoutInternals
|
||||||
|
}
|
||||||
|
? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName]
|
||||||
|
: PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"]
|
||||||
|
? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions]
|
||||||
|
: never
|
||||||
|
|
||||||
|
export const Constants = {
|
||||||
|
public: {
|
||||||
|
Enums: {},
|
||||||
|
},
|
||||||
|
} as const
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
IonButton,
|
||||||
|
IonContent,
|
||||||
|
IonHeader,
|
||||||
|
IonIcon,
|
||||||
|
IonItem,
|
||||||
|
IonLabel,
|
||||||
|
IonPage,
|
||||||
|
IonTitle,
|
||||||
|
IonToolbar,
|
||||||
|
IonText,
|
||||||
|
} from '@ionic/react';
|
||||||
|
import {
|
||||||
|
waterOutline,
|
||||||
|
fitnessOutline,
|
||||||
|
shieldCheckmarkOutline,
|
||||||
|
} from 'ionicons/icons';
|
||||||
|
import { FirebaseAuthentication } from '@capacitor-firebase/authentication';
|
||||||
|
import { Capacitor } from '@capacitor/core';
|
||||||
|
import { Redirect as RedirectRR, useHistory } from 'react-router-dom';
|
||||||
|
import { supabase } from '../supabase';
|
||||||
|
import {
|
||||||
|
Style,
|
||||||
|
setStatusBarBackground,
|
||||||
|
setStatusBarStyle,
|
||||||
|
} from '../utils/statusBar';
|
||||||
|
import { useIonViewWillEnter } from '@ionic/react';
|
||||||
|
import runnerDrinkingWaterImage from '../assets/runner-drinking-water-photorealistic.png';
|
||||||
|
|
||||||
|
const RedirectAny = RedirectRR as any;
|
||||||
|
|
||||||
|
const Account: React.FC = () => {
|
||||||
|
const history = useHistory();
|
||||||
|
const [sessionChecked, setSessionChecked] = useState(false);
|
||||||
|
const [email, setEmail] = useState<string | null>(null);
|
||||||
|
const [signingOut, setSigningOut] = useState(false);
|
||||||
|
const [signOutError, setSignOutError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
|
||||||
|
supabase.auth.getSession().then(({ data }) => {
|
||||||
|
if (!mounted) return;
|
||||||
|
setEmail(data.session?.user.email ?? null);
|
||||||
|
setSessionChecked(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: { subscription },
|
||||||
|
} = supabase.auth.onAuthStateChange((_event, session) => {
|
||||||
|
if (!mounted) return;
|
||||||
|
setEmail(session?.user.email ?? null);
|
||||||
|
setSessionChecked(true);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
history.replace('/auth');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
subscription.unsubscribe();
|
||||||
|
};
|
||||||
|
}, [history]);
|
||||||
|
|
||||||
|
const handleSignOut = async () => {
|
||||||
|
setSignOutError('');
|
||||||
|
setSigningOut(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { error } = await supabase.auth.signOut();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
setSignOutError(error.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Capacitor.isNativePlatform()) {
|
||||||
|
try {
|
||||||
|
await FirebaseAuthentication.signOut();
|
||||||
|
} catch {
|
||||||
|
// Ignore if no native Google session exists.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setEmail(null);
|
||||||
|
history.replace('/auth');
|
||||||
|
} catch (error) {
|
||||||
|
setSignOutError(
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Unable to sign out right now.',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setSigningOut(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (sessionChecked && !email) {
|
||||||
|
return <RedirectAny to="/auth" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IonPage>
|
||||||
|
<IonHeader className="ion-no-border">
|
||||||
|
<IonToolbar>
|
||||||
|
<IonTitle>Account</IonTitle>
|
||||||
|
</IonToolbar>
|
||||||
|
</IonHeader>
|
||||||
|
<IonContent fullscreen>
|
||||||
|
<div className="dashboard-shell ion-padding account-shell">
|
||||||
|
<section className="account-hero-card">
|
||||||
|
<div className="account-hero-image-wrap">
|
||||||
|
<img
|
||||||
|
src={runnerDrinkingWaterImage}
|
||||||
|
alt="Runner drinking water after a run"
|
||||||
|
className="account-hero-image"
|
||||||
|
/>
|
||||||
|
<div className="account-hero-overlay" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="account-hero-content">
|
||||||
|
<span className="eyebrow">Recovery & account</span>
|
||||||
|
<h1>{email ?? 'Loading...'}</h1>
|
||||||
|
<p>
|
||||||
|
Stay hydrated, stay consistent, and keep your habit history
|
||||||
|
safely synced across sessions.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="account-highlight-row">
|
||||||
|
<div className="account-highlight-chip">
|
||||||
|
<IonIcon icon={waterOutline} />
|
||||||
|
<span>Recovery mindset</span>
|
||||||
|
</div>
|
||||||
|
<div className="account-highlight-chip subtle">
|
||||||
|
<IonIcon icon={fitnessOutline} />
|
||||||
|
<span>Built for routines</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="stats-card account-details-card">
|
||||||
|
<IonItem lines="none" className="account-info-item">
|
||||||
|
<IonLabel>
|
||||||
|
<h2>Email</h2>
|
||||||
|
<p>{email ?? '—'}</p>
|
||||||
|
</IonLabel>
|
||||||
|
</IonItem>
|
||||||
|
|
||||||
|
<div className="account-trust-row">
|
||||||
|
<div className="account-trust-icon">
|
||||||
|
<IonIcon icon={shieldCheckmarkOutline} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3>Your data is synced</h3>
|
||||||
|
<p>
|
||||||
|
Sign in to keep your streaks and progress available whenever
|
||||||
|
you come back.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<IonButton
|
||||||
|
expand="block"
|
||||||
|
className="account-primary-button"
|
||||||
|
routerLink="/home"
|
||||||
|
>
|
||||||
|
Back to Habits
|
||||||
|
</IonButton>
|
||||||
|
{signOutError ? (
|
||||||
|
<IonText color="danger">
|
||||||
|
<p className="auth-feedback">{signOutError}</p>
|
||||||
|
</IonText>
|
||||||
|
) : null}
|
||||||
|
<div></div>
|
||||||
|
|
||||||
|
<IonButton
|
||||||
|
expand="block"
|
||||||
|
fill="outline"
|
||||||
|
color="danger"
|
||||||
|
className="account-secondary-button"
|
||||||
|
onClick={handleSignOut}
|
||||||
|
disabled={signingOut}
|
||||||
|
>
|
||||||
|
{signingOut ? 'Signing Out…' : 'Sign Out'}
|
||||||
|
</IonButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</IonContent>
|
||||||
|
</IonPage>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Account;
|
||||||
@@ -0,0 +1,398 @@
|
|||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
IonButton,
|
||||||
|
IonCard,
|
||||||
|
IonCardContent,
|
||||||
|
IonContent,
|
||||||
|
IonIcon,
|
||||||
|
IonInput,
|
||||||
|
IonItem,
|
||||||
|
IonLabel,
|
||||||
|
IonPage,
|
||||||
|
IonSegment,
|
||||||
|
IonSegmentButton,
|
||||||
|
IonText,
|
||||||
|
useIonViewWillEnter,
|
||||||
|
} from '@ionic/react';
|
||||||
|
import { FirebaseAuthentication } from '@capacitor-firebase/authentication';
|
||||||
|
import { Capacitor } from '@capacitor/core';
|
||||||
|
import { Redirect, useHistory } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Style,
|
||||||
|
setStatusBarBackground,
|
||||||
|
setStatusBarStyle,
|
||||||
|
} from '../utils/statusBar';
|
||||||
|
import { supabase } from '../supabase';
|
||||||
|
|
||||||
|
const Auth: React.FC = () => {
|
||||||
|
const history = useHistory();
|
||||||
|
const [mode, setMode] = useState<'login' | 'signup'>('login');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [googleLoading, setGoogleLoading] = useState(false);
|
||||||
|
const [appleLoading, setAppleLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [info, setInfo] = useState('');
|
||||||
|
const [verificationMessage, setVerificationMessage] = useState('');
|
||||||
|
const [sessionChecked, setSessionChecked] = useState(false);
|
||||||
|
const [hasSession, setHasSession] = useState(false);
|
||||||
|
|
||||||
|
const showError = (msg: string) => {
|
||||||
|
setError(msg);
|
||||||
|
setTimeout(() => {
|
||||||
|
setError((current) => (current === msg ? '' : current));
|
||||||
|
}, 4000);
|
||||||
|
};
|
||||||
|
|
||||||
|
useIonViewWillEnter(() => {
|
||||||
|
setStatusBarStyle(Style.Dark);
|
||||||
|
setStatusBarBackground('#f6f7fb');
|
||||||
|
});
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
|
||||||
|
supabase.auth.getSession().then(({ data }) => {
|
||||||
|
if (!mounted) return;
|
||||||
|
setHasSession(Boolean(data.session));
|
||||||
|
setSessionChecked(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: { subscription },
|
||||||
|
} = supabase.auth.onAuthStateChange((_event, session) => {
|
||||||
|
if (!mounted) return;
|
||||||
|
setHasSession(Boolean(session));
|
||||||
|
setSessionChecked(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
subscription.unsubscribe();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const formValid = useMemo(() => {
|
||||||
|
return email.trim().length > 3 && password.length >= 6;
|
||||||
|
}, [email, password]);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
setError('');
|
||||||
|
setInfo('');
|
||||||
|
setVerificationMessage('');
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (mode === 'login') {
|
||||||
|
const { error: signInError } = await supabase.auth.signInWithPassword({
|
||||||
|
email: email.trim(),
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (signInError) {
|
||||||
|
showError(signInError.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
history.replace('/home');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error: signUpError } = await supabase.auth.signUp({
|
||||||
|
email: email.trim(),
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (signUpError) {
|
||||||
|
showError(signUpError.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.session) {
|
||||||
|
history.replace('/home');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setVerificationMessage(
|
||||||
|
'Account created! Check your email for a verification link.'
|
||||||
|
);
|
||||||
|
setInfo('Once verified, come back and sign in.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGoogleLogin = async () => {
|
||||||
|
setError('');
|
||||||
|
setInfo('');
|
||||||
|
setVerificationMessage('');
|
||||||
|
setGoogleLoading(true);
|
||||||
|
|
||||||
|
if (!Capacitor.isNativePlatform()) {
|
||||||
|
showError(
|
||||||
|
'Google Sign-In is only available in the native app. Use email to sign in here.'
|
||||||
|
);
|
||||||
|
setGoogleLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await FirebaseAuthentication.signInWithGoogle();
|
||||||
|
const idToken = result.credential?.idToken;
|
||||||
|
|
||||||
|
if (!idToken) {
|
||||||
|
showError('Google sign-in did not return an ID token.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error: googleAuthError } = await supabase.auth.signInWithIdToken({
|
||||||
|
provider: 'google',
|
||||||
|
token: idToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (googleAuthError) {
|
||||||
|
showError(googleAuthError.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
history.replace('/home');
|
||||||
|
} catch (err) {
|
||||||
|
const message =
|
||||||
|
err instanceof Error ? err.message : 'Google sign-in failed.';
|
||||||
|
showError(message);
|
||||||
|
} finally {
|
||||||
|
setGoogleLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAppleLogin = async () => {
|
||||||
|
setError('');
|
||||||
|
setInfo('');
|
||||||
|
setVerificationMessage('');
|
||||||
|
setAppleLoading(true);
|
||||||
|
|
||||||
|
if (!Capacitor.isNativePlatform()) {
|
||||||
|
showError(
|
||||||
|
'Apple Sign-In is only available in the native app. Use email to sign in here.'
|
||||||
|
);
|
||||||
|
setAppleLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await FirebaseAuthentication.signInWithApple({
|
||||||
|
skipNativeAuth: true,
|
||||||
|
});
|
||||||
|
const idToken = result.credential?.idToken;
|
||||||
|
const rawNonce = result.credential?.nonce;
|
||||||
|
|
||||||
|
if (!idToken) {
|
||||||
|
showError('Apple sign-in did not return an ID token.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error: appleAuthError } = await supabase.auth.signInWithIdToken({
|
||||||
|
provider: 'apple',
|
||||||
|
token: idToken,
|
||||||
|
nonce: rawNonce || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (appleAuthError) {
|
||||||
|
showError(appleAuthError.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
history.replace('/home');
|
||||||
|
} catch (err) {
|
||||||
|
const message =
|
||||||
|
err instanceof Error ? err.message : 'Apple sign-in failed.';
|
||||||
|
showError(message);
|
||||||
|
} finally {
|
||||||
|
setAppleLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (sessionChecked && hasSession) {
|
||||||
|
return <Redirect to="/home" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IonPage>
|
||||||
|
<IonContent fullscreen style={{ '--background': '#f6f7fb' }}>
|
||||||
|
<div className="auth-shell">
|
||||||
|
<div className="auth-hero">
|
||||||
|
<span className="eyebrow">Protect your streaks</span>
|
||||||
|
<h1>Build habits that actually stick.</h1>
|
||||||
|
<p>
|
||||||
|
Track daily wins, recover faster from misses, and see which habits
|
||||||
|
are getting real momentum.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<IonCard className="auth-card">
|
||||||
|
<IonCardContent>
|
||||||
|
<IonSegment
|
||||||
|
value={mode}
|
||||||
|
onIonChange={(e) =>
|
||||||
|
setMode(e.detail.value as 'login' | 'signup')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<IonSegmentButton value="login">
|
||||||
|
<IonLabel>Login</IonLabel>
|
||||||
|
</IonSegmentButton>
|
||||||
|
<IonSegmentButton value="signup">
|
||||||
|
<IonLabel>Sign Up</IonLabel>
|
||||||
|
</IonSegmentButton>
|
||||||
|
</IonSegment>
|
||||||
|
|
||||||
|
{verificationMessage ? (
|
||||||
|
<div className="auth-message-block">
|
||||||
|
<h2>Check your inbox</h2>
|
||||||
|
<p>{verificationMessage}</p>
|
||||||
|
<IonText color="medium">
|
||||||
|
<p>{info}</p>
|
||||||
|
</IonText>
|
||||||
|
<IonButton fill="clear" onClick={() => setMode('login')}>
|
||||||
|
Back to Login
|
||||||
|
</IonButton>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="auth-form">
|
||||||
|
<IonItem lines="none" className="auth-input-item">
|
||||||
|
<IonInput
|
||||||
|
type="email"
|
||||||
|
label="Email"
|
||||||
|
labelPlacement="stacked"
|
||||||
|
value={email}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
onIonInput={(e) => setEmail(e.detail.value ?? '')}
|
||||||
|
/>
|
||||||
|
</IonItem>
|
||||||
|
|
||||||
|
<IonItem lines="none" className="auth-input-item">
|
||||||
|
<IonInput
|
||||||
|
type="password"
|
||||||
|
label="Password"
|
||||||
|
labelPlacement="stacked"
|
||||||
|
value={password}
|
||||||
|
placeholder="At least 6 characters"
|
||||||
|
onIonInput={(e) => setPassword(e.detail.value ?? '')}
|
||||||
|
/>
|
||||||
|
</IonItem>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<IonText color="danger">
|
||||||
|
<p className="auth-feedback">{error}</p>
|
||||||
|
</IonText>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{info ? (
|
||||||
|
<IonText color="medium">
|
||||||
|
<p className="auth-feedback">{info}</p>
|
||||||
|
</IonText>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<IonButton
|
||||||
|
expand="block"
|
||||||
|
disabled={
|
||||||
|
!formValid || loading || googleLoading || appleLoading
|
||||||
|
}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
>
|
||||||
|
{loading
|
||||||
|
? 'Please wait…'
|
||||||
|
: mode === 'login'
|
||||||
|
? 'Log In'
|
||||||
|
: 'Create Account'}
|
||||||
|
</IonButton>
|
||||||
|
|
||||||
|
{mode === 'login' ? (
|
||||||
|
<>
|
||||||
|
<div className="auth-divider">
|
||||||
|
<span>or</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<IonButton
|
||||||
|
expand="block"
|
||||||
|
fill="outline"
|
||||||
|
className="google-auth-button"
|
||||||
|
disabled={loading || googleLoading || appleLoading}
|
||||||
|
onClick={handleGoogleLogin}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
slot="start"
|
||||||
|
className="google-svg-wrap"
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginRight: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
style={{ display: 'block' }}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||||
|
fill="#4285F4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||||
|
fill="#34A853"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z"
|
||||||
|
fill="#FBBC05"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||||
|
fill="#EA4335"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
{googleLoading ? 'Connecting…' : 'Continue with Google'}
|
||||||
|
</IonButton>
|
||||||
|
|
||||||
|
<IonButton
|
||||||
|
expand="block"
|
||||||
|
fill="outline"
|
||||||
|
className="apple-auth-button"
|
||||||
|
disabled={loading || googleLoading || appleLoading}
|
||||||
|
onClick={handleAppleLogin}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
slot="start"
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginRight: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
style={{ display: 'block' }}
|
||||||
|
>
|
||||||
|
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.8-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
{appleLoading ? 'Connecting…' : 'Continue with Apple'}
|
||||||
|
</IonButton>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</IonCardContent>
|
||||||
|
</IonCard>
|
||||||
|
</div>
|
||||||
|
</IonContent>
|
||||||
|
</IonPage>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Auth;
|
||||||
@@ -0,0 +1,623 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
IonAlert,
|
||||||
|
IonButton,
|
||||||
|
IonButtons,
|
||||||
|
IonContent,
|
||||||
|
IonHeader,
|
||||||
|
IonIcon,
|
||||||
|
IonPage,
|
||||||
|
IonSkeletonText,
|
||||||
|
IonTitle,
|
||||||
|
IonToolbar,
|
||||||
|
useIonViewWillEnter,
|
||||||
|
} from '@ionic/react';
|
||||||
|
import { IonBackButton } from '@ionic/react';
|
||||||
|
import {
|
||||||
|
checkmarkOutline,
|
||||||
|
createOutline,
|
||||||
|
ellipseOutline,
|
||||||
|
flameOutline,
|
||||||
|
sparklesOutline,
|
||||||
|
trashOutline,
|
||||||
|
trendingUpOutline,
|
||||||
|
} from 'ionicons/icons';
|
||||||
|
import { Redirect, useHistory, useParams } from 'react-router-dom';
|
||||||
|
import type { Tables } from '../database.types';
|
||||||
|
import { supabase } from '../supabase';
|
||||||
|
import HabitFormModal from '../components/HabitFormModal';
|
||||||
|
import {
|
||||||
|
Style,
|
||||||
|
setStatusBarBackground,
|
||||||
|
setStatusBarStyle,
|
||||||
|
} from '../utils/statusBar';
|
||||||
|
|
||||||
|
interface HabitWithCompletions extends Tables<'habits'> {
|
||||||
|
completions: Tables<'habit_completions'>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (date: Date) => date.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
const calculateStreak = (dates: string[]) => {
|
||||||
|
const unique = Array.from(new Set(dates)).sort((a, b) => b.localeCompare(a));
|
||||||
|
let streak = 0;
|
||||||
|
let cursor = new Date();
|
||||||
|
|
||||||
|
if (!unique.includes(formatDate(cursor))) {
|
||||||
|
cursor.setDate(cursor.getDate() - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
while (unique.includes(formatDate(cursor))) {
|
||||||
|
streak += 1;
|
||||||
|
cursor.setDate(cursor.getDate() - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return streak;
|
||||||
|
};
|
||||||
|
|
||||||
|
const HabitDetail: React.FC = () => {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const history = useHistory();
|
||||||
|
|
||||||
|
const [sessionChecked, setSessionChecked] = useState(false);
|
||||||
|
const [userId, setUserId] = useState<string | null>(null);
|
||||||
|
const [habit, setHabit] = useState<HabitWithCompletions | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [showEditModal, setShowEditModal] = useState(false);
|
||||||
|
const [showDeleteAlert, setShowDeleteAlert] = useState(false);
|
||||||
|
|
||||||
|
const loadHabit = useCallback(async () => {
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: { session },
|
||||||
|
error: sessionError,
|
||||||
|
} = await supabase.auth.getSession();
|
||||||
|
|
||||||
|
if (sessionError) {
|
||||||
|
setError(sessionError.message);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
setUserId(null);
|
||||||
|
setSessionChecked(true);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUserId(session.user.id);
|
||||||
|
setSessionChecked(true);
|
||||||
|
|
||||||
|
const { data, error: habitError } = await supabase
|
||||||
|
.from('habits')
|
||||||
|
.select('*, completions:habit_completions(*)')
|
||||||
|
.eq('id', id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (habitError) {
|
||||||
|
if (habitError.code === 'PGRST116') {
|
||||||
|
setHabit(null);
|
||||||
|
} else {
|
||||||
|
setError(habitError.message);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setHabit(data as HabitWithCompletions);
|
||||||
|
setLoading(false);
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadHabit();
|
||||||
|
}, [loadHabit]);
|
||||||
|
|
||||||
|
const completionDates = useMemo(
|
||||||
|
() => habit?.completions.map((c) => c.completed_on) ?? [],
|
||||||
|
[habit],
|
||||||
|
);
|
||||||
|
|
||||||
|
const today = formatDate(new Date());
|
||||||
|
const doneToday = completionDates.includes(today);
|
||||||
|
|
||||||
|
const currentStreak = useMemo(
|
||||||
|
() => calculateStreak(completionDates),
|
||||||
|
[completionDates],
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalCompletions = completionDates.length;
|
||||||
|
|
||||||
|
// Calculate longest streak
|
||||||
|
const longestStreak = useMemo(() => {
|
||||||
|
const unique = Array.from(new Set(completionDates)).sort((a, b) =>
|
||||||
|
a.localeCompare(b),
|
||||||
|
);
|
||||||
|
if (unique.length === 0) return 0;
|
||||||
|
let maxStreak = 1;
|
||||||
|
let current = 1;
|
||||||
|
for (let i = 1; i < unique.length; i++) {
|
||||||
|
const prev = new Date(unique[i - 1]);
|
||||||
|
const curr = new Date(unique[i]);
|
||||||
|
const diffDays =
|
||||||
|
(curr.getTime() - prev.getTime()) / (1000 * 60 * 60 * 24);
|
||||||
|
if (diffDays === 1) {
|
||||||
|
current += 1;
|
||||||
|
maxStreak = Math.max(maxStreak, current);
|
||||||
|
} else {
|
||||||
|
current = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return maxStreak;
|
||||||
|
}, [completionDates]);
|
||||||
|
|
||||||
|
// Days since habit creation (or first completion, whichever is earlier)
|
||||||
|
const daysSinceStart = useMemo(() => {
|
||||||
|
const startDate = habit
|
||||||
|
? new Date(
|
||||||
|
Math.min(
|
||||||
|
new Date(habit.created_at).getTime(),
|
||||||
|
...completionDates.map((d) => new Date(d).getTime()),
|
||||||
|
Date.now(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: new Date();
|
||||||
|
const diff = Math.ceil(
|
||||||
|
(Date.now() - startDate.getTime()) / (1000 * 60 * 60 * 24),
|
||||||
|
);
|
||||||
|
return Math.max(diff, 1);
|
||||||
|
}, [habit, completionDates]);
|
||||||
|
|
||||||
|
const consistencyRate = Math.round((totalCompletions / daysSinceStart) * 100);
|
||||||
|
|
||||||
|
// 30-day heatmap data
|
||||||
|
const heatmapDays = useMemo(() => {
|
||||||
|
return Array.from({ length: 30 }, (_, i) => {
|
||||||
|
const date = new Date();
|
||||||
|
date.setDate(date.getDate() - (29 - i));
|
||||||
|
const key = formatDate(date);
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
completed: completionDates.includes(key),
|
||||||
|
isToday: key === today,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [completionDates, today]);
|
||||||
|
|
||||||
|
const toggleToday = async () => {
|
||||||
|
if (!userId || !habit) return;
|
||||||
|
setBusy(true);
|
||||||
|
|
||||||
|
if (doneToday) {
|
||||||
|
const existing = habit.completions.find((c) => c.completed_on === today);
|
||||||
|
if (existing) {
|
||||||
|
const { error: deleteError } = await supabase
|
||||||
|
.from('habit_completions')
|
||||||
|
.delete()
|
||||||
|
.eq('id', existing.id);
|
||||||
|
if (deleteError) setError(deleteError.message);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const { error: insertError } = await supabase
|
||||||
|
.from('habit_completions')
|
||||||
|
.insert({
|
||||||
|
habit_id: habit.id,
|
||||||
|
user_id: userId,
|
||||||
|
completed_on: today,
|
||||||
|
});
|
||||||
|
if (insertError) setError(insertError.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadHabit();
|
||||||
|
setBusy(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateHabit = async (values: {
|
||||||
|
name: string;
|
||||||
|
targetDescription: string;
|
||||||
|
color: string;
|
||||||
|
}) => {
|
||||||
|
if (!userId || !habit) throw new Error('Habit not found.');
|
||||||
|
|
||||||
|
const { error: updateError } = await supabase
|
||||||
|
.from('habits')
|
||||||
|
.update({
|
||||||
|
name: values.name,
|
||||||
|
target_description: values.targetDescription || null,
|
||||||
|
color: values.color,
|
||||||
|
})
|
||||||
|
.eq('id', habit.id);
|
||||||
|
|
||||||
|
if (updateError) throw updateError;
|
||||||
|
await loadHabit();
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteHabit = async () => {
|
||||||
|
if (!habit) return;
|
||||||
|
setBusy(true);
|
||||||
|
const { error: deleteError } = await supabase
|
||||||
|
.from('habits')
|
||||||
|
.delete()
|
||||||
|
.eq('id', habit.id);
|
||||||
|
if (deleteError) {
|
||||||
|
setError(deleteError.message);
|
||||||
|
setBusy(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
history.push('/home');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (sessionChecked && !userId) {
|
||||||
|
return <Redirect to="/auth" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IonPage>
|
||||||
|
<IonHeader className="ion-no-border">
|
||||||
|
<IonToolbar>
|
||||||
|
<IonButtons slot="start">
|
||||||
|
<IonBackButton defaultHref="/home" />
|
||||||
|
</IonButtons>
|
||||||
|
<IonTitle>{habit?.name ?? 'Habit'}</IonTitle>
|
||||||
|
<IonButtons slot="end">
|
||||||
|
{habit && (
|
||||||
|
<>
|
||||||
|
<IonButton fill="clear" onClick={() => setShowEditModal(true)}>
|
||||||
|
<IonIcon icon={createOutline} />
|
||||||
|
</IonButton>
|
||||||
|
<IonButton
|
||||||
|
fill="clear"
|
||||||
|
color="danger"
|
||||||
|
onClick={() => setShowDeleteAlert(true)}
|
||||||
|
>
|
||||||
|
<IonIcon icon={trashOutline} />
|
||||||
|
</IonButton>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</IonButtons>
|
||||||
|
</IonToolbar>
|
||||||
|
</IonHeader>
|
||||||
|
|
||||||
|
<IonContent fullscreen>
|
||||||
|
<div className="dashboard-shell ion-padding">
|
||||||
|
{/* Loading state */}
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<IonSkeletonText
|
||||||
|
animated
|
||||||
|
style={{ width: '60%', height: '24px', borderRadius: '8px' }}
|
||||||
|
/>
|
||||||
|
<IonSkeletonText
|
||||||
|
animated
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '120px',
|
||||||
|
borderRadius: '24px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<IonSkeletonText
|
||||||
|
animated
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '160px',
|
||||||
|
borderRadius: '24px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<IonSkeletonText
|
||||||
|
animated
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100px',
|
||||||
|
borderRadius: '24px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : error ? (
|
||||||
|
/* Error state */
|
||||||
|
<div className="inline-message error-card">
|
||||||
|
<h3>Something went wrong</h3>
|
||||||
|
<p>{error}</p>
|
||||||
|
<IonButton onClick={loadHabit}>Try Again</IonButton>
|
||||||
|
</div>
|
||||||
|
) : !habit ? (
|
||||||
|
/* Not found state */
|
||||||
|
<div className="inline-message">
|
||||||
|
<h3>Habit not found</h3>
|
||||||
|
<p>
|
||||||
|
This habit may have been deleted or you don't have access
|
||||||
|
to it.
|
||||||
|
</p>
|
||||||
|
<IonButton routerLink="/home">Back to habits</IonButton>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Habit info card */}
|
||||||
|
<section className="hero-card compact">
|
||||||
|
<div className="habit-copy-topline">
|
||||||
|
<span
|
||||||
|
className="habit-color-pill"
|
||||||
|
style={{ background: habit.color }}
|
||||||
|
/>
|
||||||
|
<span className="eyebrow">Your routine</span>
|
||||||
|
</div>
|
||||||
|
<h1>{habit.name}</h1>
|
||||||
|
<p>
|
||||||
|
{habit.target_description ||
|
||||||
|
'Show up today and keep the chain alive.'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Today toggle */}
|
||||||
|
<div style={{ marginTop: '18px' }}>
|
||||||
|
<IonButton
|
||||||
|
expand="block"
|
||||||
|
fill={doneToday ? 'solid' : 'outline'}
|
||||||
|
onClick={toggleToday}
|
||||||
|
disabled={busy}
|
||||||
|
style={
|
||||||
|
doneToday
|
||||||
|
? ({
|
||||||
|
'--background': habit.color,
|
||||||
|
'--background-hover': habit.color,
|
||||||
|
'--background-activated': habit.color,
|
||||||
|
'--border-radius': '18px',
|
||||||
|
minHeight: '50px',
|
||||||
|
} as any)
|
||||||
|
: ({
|
||||||
|
'--border-color': habit.color,
|
||||||
|
'--color': habit.color,
|
||||||
|
'--border-radius': '18px',
|
||||||
|
minHeight: '50px',
|
||||||
|
} as any)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<IonIcon
|
||||||
|
icon={doneToday ? checkmarkOutline : ellipseOutline}
|
||||||
|
slot="start"
|
||||||
|
/>
|
||||||
|
{doneToday ? 'Completed today' : "Mark today's habit done"}
|
||||||
|
</IonButton>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* All-time stats */}
|
||||||
|
<section>
|
||||||
|
<p className="home-section-label">All-time stats</p>
|
||||||
|
<div className="stack-list">
|
||||||
|
{/* Stats row */}
|
||||||
|
<div
|
||||||
|
className="hero-card compact"
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '1fr 1fr 1fr',
|
||||||
|
gap: '12px',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '1.8rem',
|
||||||
|
fontWeight: 800,
|
||||||
|
color: habit.color,
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{totalCompletions}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '0.78rem',
|
||||||
|
color: 'var(--ion-color-medium)',
|
||||||
|
marginTop: '4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
total done
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '1.8rem',
|
||||||
|
fontWeight: 800,
|
||||||
|
color: habit.color,
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{longestStreak}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '0.78rem',
|
||||||
|
color: 'var(--ion-color-medium)',
|
||||||
|
marginTop: '4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
best streak
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '1.8rem',
|
||||||
|
fontWeight: 800,
|
||||||
|
color: habit.color,
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{consistencyRate}%
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '0.78rem',
|
||||||
|
color: 'var(--ion-color-medium)',
|
||||||
|
marginTop: '4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
consistency
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Current streak highlight */}
|
||||||
|
<div
|
||||||
|
className="hero-card compact"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '14px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '48px',
|
||||||
|
height: '48px',
|
||||||
|
borderRadius: '16px',
|
||||||
|
background: `${habit.color}18`,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IonIcon
|
||||||
|
icon={flameOutline}
|
||||||
|
style={{ color: habit.color, fontSize: '1.3rem' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: '1rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: 'var(--ion-color-dark)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{currentStreak} day{currentStreak === 1 ? '' : 's'} and
|
||||||
|
counting
|
||||||
|
</h3>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: '4px 0 0',
|
||||||
|
color: 'var(--ion-color-medium)',
|
||||||
|
fontSize: '0.84rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{currentStreak > 0
|
||||||
|
? `You've shown up every day since ${(() => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() - currentStreak + 1);
|
||||||
|
return d.toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
})()}.`
|
||||||
|
: "Start today and build a streak you're proud of."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 30-day heatmap */}
|
||||||
|
<section>
|
||||||
|
<p className="home-section-label">Last 30 days</p>
|
||||||
|
<div className="hero-card compact">
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(7, 1fr)',
|
||||||
|
gap: '6px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{heatmapDays.map((day) => (
|
||||||
|
<div
|
||||||
|
key={day.key}
|
||||||
|
title={
|
||||||
|
day.isToday
|
||||||
|
? 'Today'
|
||||||
|
: new Date(day.key).toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
aspectRatio: '1',
|
||||||
|
borderRadius: '6px',
|
||||||
|
background: day.completed
|
||||||
|
? habit.color
|
||||||
|
: `${habit.color}14`,
|
||||||
|
border: day.isToday
|
||||||
|
? `2px solid ${habit.color}`
|
||||||
|
: '2px solid transparent',
|
||||||
|
transition: 'background 0.2s',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginTop: '12px',
|
||||||
|
fontSize: '0.74rem',
|
||||||
|
color: 'var(--ion-color-medium)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>30 days ago</span>
|
||||||
|
<span>
|
||||||
|
<IonIcon
|
||||||
|
icon={trendingUpOutline}
|
||||||
|
style={{ verticalAlign: 'middle', marginRight: '4px' }}
|
||||||
|
/>
|
||||||
|
{heatmapDays.filter((d) => d.completed).length} of 30 days
|
||||||
|
</span>
|
||||||
|
<span>Today</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Edit modal */}
|
||||||
|
{showEditModal && (
|
||||||
|
<HabitFormModal
|
||||||
|
isOpen={showEditModal}
|
||||||
|
onDismiss={() => setShowEditModal(false)}
|
||||||
|
onSubmit={updateHabit}
|
||||||
|
initialValues={{
|
||||||
|
name: habit.name,
|
||||||
|
targetDescription: habit.target_description ?? '',
|
||||||
|
color: habit.color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete confirmation */}
|
||||||
|
<IonAlert
|
||||||
|
isOpen={showDeleteAlert}
|
||||||
|
onDidDismiss={() => setShowDeleteAlert(false)}
|
||||||
|
header="Delete habit?"
|
||||||
|
message={`Are you sure you want to delete "${habit?.name ?? ''}"? This will also remove all its completion history. This action cannot be undone.`}
|
||||||
|
buttons={[
|
||||||
|
{
|
||||||
|
text: 'Cancel',
|
||||||
|
role: 'cancel',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Delete',
|
||||||
|
role: 'destructive',
|
||||||
|
handler: deleteHabit,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</IonContent>
|
||||||
|
</IonPage>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HabitDetail;
|
||||||
+478
-8
@@ -1,23 +1,493 @@
|
|||||||
import React from 'react';
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
import {
|
import {
|
||||||
|
IonButton,
|
||||||
|
IonButtons,
|
||||||
IonContent,
|
IonContent,
|
||||||
IonHeader,
|
IonHeader,
|
||||||
|
IonIcon,
|
||||||
IonPage,
|
IonPage,
|
||||||
IonTitle,
|
IonRefresher,
|
||||||
|
IonRefresherContent,
|
||||||
|
IonSkeletonText,
|
||||||
IonToolbar,
|
IonToolbar,
|
||||||
} from '@ionic/react';
|
} from '@ionic/react';
|
||||||
|
import {
|
||||||
|
addOutline,
|
||||||
|
analyticsOutline,
|
||||||
|
checkmarkCircle,
|
||||||
|
personCircleOutline,
|
||||||
|
sparklesOutline,
|
||||||
|
} from 'ionicons/icons';
|
||||||
|
import { PushNotifications } from '@capacitor/push-notifications';
|
||||||
|
import { Capacitor } from '@capacitor/core';
|
||||||
|
import { Redirect, useHistory } from 'react-router-dom';
|
||||||
|
import type { Tables } from '../database.types';
|
||||||
|
import { supabase } from '../supabase';
|
||||||
|
import EmptyState from '../components/EmptyState';
|
||||||
|
import HabitCard from '../components/HabitCard';
|
||||||
|
import HabitFormModal from '../components/HabitFormModal';
|
||||||
|
|
||||||
const Home: React.FC = () => (
|
interface HabitWithMeta extends Tables<'habits'> {
|
||||||
|
completions: Tables<'habit_completions'>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (date: Date) => date.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
const calculateCurrentStreak = (dates: string[]) => {
|
||||||
|
const unique = Array.from(new Set(dates)).sort((a, b) => b.localeCompare(a));
|
||||||
|
let streak = 0;
|
||||||
|
let cursor = new Date();
|
||||||
|
|
||||||
|
if (!unique.includes(formatDate(cursor))) {
|
||||||
|
cursor.setDate(cursor.getDate() - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
while (unique.includes(formatDate(cursor))) {
|
||||||
|
streak += 1;
|
||||||
|
cursor.setDate(cursor.getDate() - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return streak;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Home: React.FC = () => {
|
||||||
|
const history = useHistory();
|
||||||
|
const [sessionChecked, setSessionChecked] = useState(false);
|
||||||
|
const [userId, setUserId] = useState<string | null>(null);
|
||||||
|
const [habits, setHabits] = useState<HabitWithMeta[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
|
const [busyHabitId, setBusyHabitId] = useState<string | null>(null);
|
||||||
|
const pushTokenRef = useRef<string | null>(null);
|
||||||
|
const pushListenersReadyRef = useRef(false);
|
||||||
|
|
||||||
|
const showError = (message: string) => {
|
||||||
|
setError(message);
|
||||||
|
window.setTimeout(() => setError(''), 4000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const registerPushNotifications = useCallback(
|
||||||
|
async (currentUserId: string) => {
|
||||||
|
if (!Capacitor.isNativePlatform()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pushListenersReadyRef.current) {
|
||||||
|
pushListenersReadyRef.current = true;
|
||||||
|
|
||||||
|
await PushNotifications.removeAllListeners();
|
||||||
|
|
||||||
|
PushNotifications.addListener('registration', async (token) => {
|
||||||
|
if (pushTokenRef.current === token.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pushTokenRef.current = token.value;
|
||||||
|
const { error: updateError } = await supabase
|
||||||
|
.from('habits')
|
||||||
|
.update({ fcm_token: token.value })
|
||||||
|
.eq('user_id', currentUserId);
|
||||||
|
|
||||||
|
if (updateError) {
|
||||||
|
showError(updateError.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
PushNotifications.addListener(
|
||||||
|
'registrationError',
|
||||||
|
(registrationError) => {
|
||||||
|
showError(registrationError.error ?? 'Push registration failed.');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissionStatus = await PushNotifications.checkPermissions();
|
||||||
|
if (permissionStatus.receive === 'prompt') {
|
||||||
|
const requested = await PushNotifications.requestPermissions();
|
||||||
|
if (requested.receive !== 'granted') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (permissionStatus.receive !== 'granted') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await PushNotifications.register();
|
||||||
|
},
|
||||||
|
[showError]
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadDashboard = useCallback(async () => {
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: { session },
|
||||||
|
error: sessionError,
|
||||||
|
} = await supabase.auth.getSession();
|
||||||
|
|
||||||
|
if (sessionError) {
|
||||||
|
setError(sessionError.message);
|
||||||
|
setSessionChecked(true);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
setUserId(null);
|
||||||
|
setSessionChecked(true);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUserId(session.user.id);
|
||||||
|
setSessionChecked(true);
|
||||||
|
await registerPushNotifications(session.user.id);
|
||||||
|
|
||||||
|
const { data, error: habitsError } = await supabase
|
||||||
|
.from('habits')
|
||||||
|
.select('*, completions:habit_completions(*)')
|
||||||
|
.order('created_at', { ascending: true });
|
||||||
|
|
||||||
|
if (habitsError) {
|
||||||
|
setError(habitsError.message);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setHabits((data ?? []) as HabitWithMeta[]);
|
||||||
|
setLoading(false);
|
||||||
|
}, [registerPushNotifications]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadDashboard();
|
||||||
|
}, [loadDashboard]);
|
||||||
|
|
||||||
|
const today = formatDate(new Date());
|
||||||
|
|
||||||
|
const habitStats = useMemo(() => {
|
||||||
|
const last7Days = Array.from({ length: 7 }, (_, index) => {
|
||||||
|
const date = new Date();
|
||||||
|
date.setDate(date.getDate() - index);
|
||||||
|
return formatDate(date);
|
||||||
|
});
|
||||||
|
|
||||||
|
return habits.reduce<
|
||||||
|
Record<string, { streak: number; weekRate: number; doneToday: boolean }>
|
||||||
|
>((acc, habit) => {
|
||||||
|
const completionDates = habit.completions.map(
|
||||||
|
(entry) => entry.completed_on
|
||||||
|
);
|
||||||
|
const completedInWeek = completionDates.filter((date) =>
|
||||||
|
last7Days.includes(date)
|
||||||
|
).length;
|
||||||
|
|
||||||
|
acc[habit.id] = {
|
||||||
|
streak: calculateCurrentStreak(completionDates),
|
||||||
|
weekRate: Math.round((completedInWeek / last7Days.length) * 100),
|
||||||
|
doneToday: completionDates.includes(today),
|
||||||
|
};
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
}, [habits, today]);
|
||||||
|
|
||||||
|
const totalCompletedToday = habits.filter(
|
||||||
|
(habit) => habitStats[habit.id]?.doneToday
|
||||||
|
).length;
|
||||||
|
const longestStreak = habits.reduce(
|
||||||
|
(max, habit) => Math.max(max, habitStats[habit.id]?.streak ?? 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const completionRatio = habits.length
|
||||||
|
? totalCompletedToday / habits.length
|
||||||
|
: 0;
|
||||||
|
const pendingCount = Math.max(habits.length - totalCompletedToday, 0);
|
||||||
|
|
||||||
|
const createHabit = async (values: {
|
||||||
|
name: string;
|
||||||
|
targetDescription: string;
|
||||||
|
color: string;
|
||||||
|
}) => {
|
||||||
|
if (!userId) throw new Error('You need to be logged in.');
|
||||||
|
|
||||||
|
const { error: insertError } = await supabase.from('habits').insert({
|
||||||
|
user_id: userId,
|
||||||
|
name: values.name,
|
||||||
|
target_description: values.targetDescription || null,
|
||||||
|
color: values.color,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (insertError) throw insertError;
|
||||||
|
await loadDashboard();
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleHabit = async (habit: HabitWithMeta) => {
|
||||||
|
if (!userId) return;
|
||||||
|
setBusyHabitId(habit.id);
|
||||||
|
|
||||||
|
const existing = habit.completions.find(
|
||||||
|
(entry) => entry.completed_on === today
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
const { error: deleteError } = await supabase
|
||||||
|
.from('habit_completions')
|
||||||
|
.delete()
|
||||||
|
.eq('id', existing.id);
|
||||||
|
if (deleteError) showError(deleteError.message);
|
||||||
|
} else {
|
||||||
|
const { error: insertError } = await supabase
|
||||||
|
.from('habit_completions')
|
||||||
|
.insert({
|
||||||
|
habit_id: habit.id,
|
||||||
|
user_id: userId,
|
||||||
|
completed_on: today,
|
||||||
|
});
|
||||||
|
if (insertError) {
|
||||||
|
showError(insertError.message);
|
||||||
|
} else {
|
||||||
|
const { error: pushError } = await supabase.functions.invoke(
|
||||||
|
'send-habit-completion-push',
|
||||||
|
{
|
||||||
|
body: { habitId: habit.id },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (pushError) {
|
||||||
|
showError(pushError.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadDashboard();
|
||||||
|
setBusyHabitId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteHabit = async (habitId: string) => {
|
||||||
|
setBusyHabitId(habitId);
|
||||||
|
const { error: deleteError } = await supabase
|
||||||
|
.from('habits')
|
||||||
|
.delete()
|
||||||
|
.eq('id', habitId);
|
||||||
|
if (deleteError) {
|
||||||
|
showError(deleteError.message);
|
||||||
|
}
|
||||||
|
await loadDashboard();
|
||||||
|
setBusyHabitId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
void PushNotifications.removeAllListeners();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (sessionChecked && !userId) {
|
||||||
|
return <Redirect to="/auth" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
<IonPage>
|
<IonPage>
|
||||||
<IonHeader>
|
<IonHeader className="ion-no-border home-header">
|
||||||
<IonToolbar>
|
<IonToolbar>
|
||||||
<IonTitle>Home</IonTitle>
|
<IonButtons slot="end">
|
||||||
|
<IonButton
|
||||||
|
className="soft-icon-button"
|
||||||
|
onClick={() => history.push('/stats')}
|
||||||
|
>
|
||||||
|
<IonIcon icon={analyticsOutline} />
|
||||||
|
</IonButton>
|
||||||
|
<IonButton
|
||||||
|
className="soft-icon-button"
|
||||||
|
onClick={() => history.push('/account')}
|
||||||
|
>
|
||||||
|
<IonIcon icon={personCircleOutline} />
|
||||||
|
</IonButton>
|
||||||
|
</IonButtons>
|
||||||
</IonToolbar>
|
</IonToolbar>
|
||||||
</IonHeader>
|
</IonHeader>
|
||||||
<IonContent className="ion-padding">
|
|
||||||
<p>Welcome! Start building your app here.</p>
|
<IonContent fullscreen className="home-content">
|
||||||
|
<IonRefresher
|
||||||
|
slot="fixed"
|
||||||
|
onIonRefresh={async (e) => {
|
||||||
|
await loadDashboard();
|
||||||
|
e.detail.complete();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IonRefresherContent />
|
||||||
|
</IonRefresher>
|
||||||
|
|
||||||
|
<div className="dashboard-shell home-shell ion-padding">
|
||||||
|
<section className="habits-hero">
|
||||||
|
<div className="habits-hero-top">
|
||||||
|
<div>
|
||||||
|
<p className="hero-kicker">Habits ({habits.length})</p>
|
||||||
|
<div className="hero-title-wrap">
|
||||||
|
<h1>Daily</h1>
|
||||||
|
<span>Weekly</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<IonButton
|
||||||
|
className="hero-add-button"
|
||||||
|
fill="clear"
|
||||||
|
onClick={() => setShowCreateModal(true)}
|
||||||
|
>
|
||||||
|
<IonIcon icon={addOutline} />
|
||||||
|
</IonButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hero-progress-track" aria-hidden="true">
|
||||||
|
<div
|
||||||
|
className="hero-progress-fill"
|
||||||
|
style={{ width: `${Math.max(completionRatio * 100, 6)}%` }}
|
||||||
|
>
|
||||||
|
<span className="hero-progress-dot" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hero-progress-meta">
|
||||||
|
<span>{totalCompletedToday} completed today</span>
|
||||||
|
<span>{Math.round(completionRatio * 100)}% completed</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="home-routine-panel">
|
||||||
|
<div className="stack-list home-habit-stack">
|
||||||
|
{Array.from({ length: 4 }).map((_, index) => (
|
||||||
|
<div
|
||||||
|
className="habit-card habit-card--editorial habit-card-skeleton"
|
||||||
|
key={`habit-skeleton-${index}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<IonSkeletonText
|
||||||
|
animated
|
||||||
|
className="habit-skeleton-check"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="habit-card-main">
|
||||||
|
<div className="habit-copy">
|
||||||
|
<div className="habit-copy-topline">
|
||||||
|
<IonSkeletonText
|
||||||
|
animated
|
||||||
|
className="habit-skeleton-title"
|
||||||
|
/>
|
||||||
|
<IonSkeletonText
|
||||||
|
animated
|
||||||
|
className="habit-skeleton-color-pill"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<IonSkeletonText
|
||||||
|
animated
|
||||||
|
className="habit-skeleton-description"
|
||||||
|
/>
|
||||||
|
<div className="habit-metrics-row">
|
||||||
|
<IonSkeletonText
|
||||||
|
animated
|
||||||
|
className="habit-skeleton-metric"
|
||||||
|
/>
|
||||||
|
<IonSkeletonText
|
||||||
|
animated
|
||||||
|
className="habit-skeleton-metric short"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<IonSkeletonText
|
||||||
|
animated
|
||||||
|
className="habit-skeleton-action"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="inline-message error-card home-inline-card">
|
||||||
|
<h3>Couldn't load your habits</h3>
|
||||||
|
<p>{error}</p>
|
||||||
|
<IonButton onClick={loadDashboard}>Try Again</IonButton>
|
||||||
|
</div>
|
||||||
|
) : habits.length === 0 ? (
|
||||||
|
<div className="home-routine-panel">
|
||||||
|
<EmptyState
|
||||||
|
title="Start your first routine"
|
||||||
|
message="Create one small daily habit and begin building your streak story today."
|
||||||
|
actionLabel="Create First Habit"
|
||||||
|
onAction={() => setShowCreateModal(true)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<section className="home-routine-panel">
|
||||||
|
<div className="stack-list home-habit-stack">
|
||||||
|
{habits.map((habit) => (
|
||||||
|
<HabitCard
|
||||||
|
key={habit.id}
|
||||||
|
habit={habit}
|
||||||
|
completedToday={habitStats[habit.id]?.doneToday ?? false}
|
||||||
|
currentStreak={habitStats[habit.id]?.streak ?? 0}
|
||||||
|
completionRate={habitStats[habit.id]?.weekRate ?? 0}
|
||||||
|
onToggle={() => toggleHabit(habit)}
|
||||||
|
onDelete={() => deleteHabit(habit.id)}
|
||||||
|
busy={busyHabitId === habit.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="home-complete-section">
|
||||||
|
<p className="home-section-label">Momentum</p>
|
||||||
|
<div className="home-summary-card">
|
||||||
|
<div
|
||||||
|
className="summary-ring"
|
||||||
|
style={{
|
||||||
|
background: `conic-gradient(#7c3aed 0deg, #a855f7 ${completionRatio * 360}deg, rgba(109, 40, 217, 0.08) ${completionRatio * 360}deg 360deg)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="summary-ring-inner">
|
||||||
|
{Math.round(completionRatio * 100)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="summary-copy">
|
||||||
|
<h3>Keep your rhythm</h3>
|
||||||
|
<p>
|
||||||
|
{pendingCount === 0
|
||||||
|
? 'Everything is complete today. Enjoy the streak.'
|
||||||
|
: `${pendingCount} habit${pendingCount === 1 ? '' : 's'} left to protect today's routine.`}
|
||||||
|
</p>
|
||||||
|
<div className="summary-metrics">
|
||||||
|
<span>
|
||||||
|
<IonIcon icon={sparklesOutline} /> {totalCompletedToday}{' '}
|
||||||
|
done
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<IonIcon icon={checkmarkCircle} /> {longestStreak} day
|
||||||
|
best streak
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<HabitFormModal
|
||||||
|
isOpen={showCreateModal}
|
||||||
|
onDismiss={() => setShowCreateModal(false)}
|
||||||
|
onSubmit={createHabit}
|
||||||
|
/>
|
||||||
</IonContent>
|
</IonContent>
|
||||||
</IonPage>
|
</IonPage>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default Home;
|
export default Home;
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
IonButton,
|
||||||
|
IonButtons,
|
||||||
|
IonContent,
|
||||||
|
IonHeader,
|
||||||
|
IonIcon,
|
||||||
|
IonPage,
|
||||||
|
IonSegment,
|
||||||
|
IonSegmentButton,
|
||||||
|
IonTitle,
|
||||||
|
IonToolbar,
|
||||||
|
} from '@ionic/react';
|
||||||
|
import { IonBackButton } from '@ionic/react';
|
||||||
|
import { calendarOutline } from 'ionicons/icons';
|
||||||
|
import { Redirect } from 'react-router-dom';
|
||||||
|
import type { Tables } from '../database.types';
|
||||||
|
import StatsHabitCard from '../components/StatsHabitCard';
|
||||||
|
import { supabase } from '../supabase';
|
||||||
|
import {
|
||||||
|
Style,
|
||||||
|
setStatusBarBackground,
|
||||||
|
setStatusBarStyle,
|
||||||
|
} from '../utils/statusBar';
|
||||||
|
import { useIonViewWillEnter } from '@ionic/react';
|
||||||
|
|
||||||
|
interface HabitWithMeta extends Tables<'habits'> {
|
||||||
|
completions: Tables<'habit_completions'>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (date: Date) => date.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
const calculateCurrentStreak = (dates: string[]) => {
|
||||||
|
const unique = Array.from(new Set(dates)).sort((a, b) => b.localeCompare(a));
|
||||||
|
let streak = 0;
|
||||||
|
let cursor = new Date();
|
||||||
|
|
||||||
|
if (!unique.includes(formatDate(cursor))) {
|
||||||
|
cursor.setDate(cursor.getDate() - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
while (unique.includes(formatDate(cursor))) {
|
||||||
|
streak += 1;
|
||||||
|
cursor.setDate(cursor.getDate() - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return streak;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Stats: React.FC = () => {
|
||||||
|
const [period, setPeriod] = useState<'7' | '30'>('7');
|
||||||
|
const [sessionChecked, setSessionChecked] = useState(false);
|
||||||
|
const [userId, setUserId] = useState<string | null>(null);
|
||||||
|
const [habits, setHabits] = useState<HabitWithMeta[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
const {
|
||||||
|
data: { session },
|
||||||
|
} = await supabase.auth.getSession();
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
setSessionChecked(true);
|
||||||
|
setUserId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUserId(session.user.id);
|
||||||
|
setSessionChecked(true);
|
||||||
|
|
||||||
|
const { data } = await supabase
|
||||||
|
.from('habits')
|
||||||
|
.select('*, completions:habit_completions(*)')
|
||||||
|
.order('created_at', { ascending: true });
|
||||||
|
|
||||||
|
setHabits((data ?? []) as HabitWithMeta[]);
|
||||||
|
};
|
||||||
|
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const days = Number(period);
|
||||||
|
const visibleDates = useMemo(() => {
|
||||||
|
return Array.from({ length: days }, (_, index) => {
|
||||||
|
const date = new Date();
|
||||||
|
date.setDate(date.getDate() - index);
|
||||||
|
return formatDate(date);
|
||||||
|
});
|
||||||
|
}, [days]);
|
||||||
|
|
||||||
|
const summary = useMemo(() => {
|
||||||
|
return habits.map((habit) => {
|
||||||
|
const completionDates = habit.completions.map(
|
||||||
|
(entry) => entry.completed_on,
|
||||||
|
);
|
||||||
|
const completedCount = completionDates.filter((date) =>
|
||||||
|
visibleDates.includes(date),
|
||||||
|
).length;
|
||||||
|
return {
|
||||||
|
id: habit.id,
|
||||||
|
name: habit.name,
|
||||||
|
color: habit.color,
|
||||||
|
completedCount,
|
||||||
|
streak: calculateCurrentStreak(completionDates),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [habits, visibleDates]);
|
||||||
|
|
||||||
|
if (sessionChecked && !userId) {
|
||||||
|
return <Redirect to="/auth" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IonPage>
|
||||||
|
<IonHeader className="ion-no-border">
|
||||||
|
<IonToolbar>
|
||||||
|
<IonButtons slot="start">
|
||||||
|
<IonBackButton defaultHref="/home" />
|
||||||
|
</IonButtons>
|
||||||
|
<IonTitle>Stats</IonTitle>
|
||||||
|
</IonToolbar>
|
||||||
|
</IonHeader>
|
||||||
|
<IonContent fullscreen>
|
||||||
|
<div className="dashboard-shell ion-padding">
|
||||||
|
<section className="hero-card compact">
|
||||||
|
<span className="eyebrow">Consistency view</span>
|
||||||
|
<h1>See which habits you're actually keeping.</h1>
|
||||||
|
<p>
|
||||||
|
Flip between the last week and month to spot patterns before a
|
||||||
|
streak disappears.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="stats-filter-row">
|
||||||
|
<IonIcon icon={calendarOutline} />
|
||||||
|
<IonSegment
|
||||||
|
value={period}
|
||||||
|
onIonChange={(e) => setPeriod(e.detail.value as '7' | '30')}
|
||||||
|
>
|
||||||
|
<IonSegmentButton value="7">Last 7 days</IonSegmentButton>
|
||||||
|
<IonSegmentButton value="30">Last 30 days</IonSegmentButton>
|
||||||
|
</IonSegment>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{summary.length === 0 ? (
|
||||||
|
<div className="inline-message">
|
||||||
|
<h3>No stats yet</h3>
|
||||||
|
<p>
|
||||||
|
Create a habit and start checking it off daily to unlock
|
||||||
|
consistency insights.
|
||||||
|
</p>
|
||||||
|
<IonButton routerLink="/home">Go to dashboard</IonButton>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="stack-list">
|
||||||
|
{summary.map((habit) => (
|
||||||
|
<StatsHabitCard
|
||||||
|
key={habit.id}
|
||||||
|
name={habit.name}
|
||||||
|
color={habit.color}
|
||||||
|
completedCount={habit.completedCount}
|
||||||
|
totalDays={days}
|
||||||
|
streak={habit.streak}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</IonContent>
|
||||||
|
</IonPage>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Stats;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
import type { Database } from './database.types';
|
||||||
|
|
||||||
|
export const supabase = createClient<Database>(
|
||||||
|
'https://zillycfjfbzofltgyjwy.supabase.co',
|
||||||
|
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InppbGx5Y2ZqZmJ6b2ZsdGd5and5Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzkxMjYzODQsImV4cCI6MjA5NDcwMjM4NH0.BB4ccBEQI2krc7SseC73234Nfya-92VGcTELNQdzt50',
|
||||||
|
);
|
||||||
+892
-41
@@ -1,65 +1,916 @@
|
|||||||
/* Ionic CSS Variables — customise to match your app's brand */
|
|
||||||
:root {
|
:root {
|
||||||
--ion-color-primary: #3880ff;
|
--ion-color-primary: #6d28d9;
|
||||||
--ion-color-primary-rgb: 56, 128, 255;
|
--ion-color-primary-rgb: 109, 40, 217;
|
||||||
--ion-color-primary-contrast: #ffffff;
|
--ion-color-primary-contrast: #ffffff;
|
||||||
--ion-color-primary-contrast-rgb: 255, 255, 255;
|
--ion-color-primary-contrast-rgb: 255, 255, 255;
|
||||||
--ion-color-primary-shade: #3171e0;
|
--ion-color-primary-shade: #5b21b6;
|
||||||
--ion-color-primary-tint: #4c8dff;
|
--ion-color-primary-tint: #7c3aed;
|
||||||
|
|
||||||
--ion-color-secondary: #3dc2ff;
|
--ion-color-secondary: #0f766e;
|
||||||
--ion-color-secondary-rgb: 61, 194, 255;
|
--ion-color-secondary-rgb: 15, 118, 110;
|
||||||
--ion-color-secondary-contrast: #ffffff;
|
--ion-color-secondary-contrast: #ffffff;
|
||||||
--ion-color-secondary-contrast-rgb: 255, 255, 255;
|
--ion-color-secondary-contrast-rgb: 255, 255, 255;
|
||||||
--ion-color-secondary-shade: #36abe0;
|
--ion-color-secondary-shade: #115e59;
|
||||||
--ion-color-secondary-tint: #50c8ff;
|
--ion-color-secondary-tint: #14b8a6;
|
||||||
|
|
||||||
--ion-color-tertiary: #5260ff;
|
--ion-color-tertiary: #ea580c;
|
||||||
--ion-color-tertiary-rgb: 82, 96, 255;
|
--ion-color-tertiary-rgb: 234, 88, 12;
|
||||||
--ion-color-tertiary-contrast: #ffffff;
|
--ion-color-tertiary-contrast: #ffffff;
|
||||||
--ion-color-tertiary-contrast-rgb: 255, 255, 255;
|
--ion-color-tertiary-contrast-rgb: 255, 255, 255;
|
||||||
--ion-color-tertiary-shade: #4854e0;
|
--ion-color-tertiary-shade: #c2410c;
|
||||||
--ion-color-tertiary-tint: #6370ff;
|
--ion-color-tertiary-tint: #f97316;
|
||||||
|
|
||||||
--ion-color-success: #2dd36f;
|
--ion-color-success: #16a34a;
|
||||||
--ion-color-success-rgb: 45, 211, 111;
|
--ion-color-success-rgb: 22, 163, 74;
|
||||||
--ion-color-success-contrast: #ffffff;
|
--ion-color-success-contrast: #ffffff;
|
||||||
--ion-color-success-contrast-rgb: 255, 255, 255;
|
--ion-color-success-contrast-rgb: 255, 255, 255;
|
||||||
--ion-color-success-shade: #28ba62;
|
--ion-color-success-shade: #15803d;
|
||||||
--ion-color-success-tint: #42d77d;
|
--ion-color-success-tint: #22c55e;
|
||||||
|
|
||||||
--ion-color-warning: #ffc409;
|
--ion-color-warning: #f59e0b;
|
||||||
--ion-color-warning-rgb: 255, 196, 9;
|
--ion-color-warning-rgb: 245, 158, 11;
|
||||||
--ion-color-warning-contrast: #000000;
|
--ion-color-warning-contrast: #1f2937;
|
||||||
--ion-color-warning-contrast-rgb: 0, 0, 0;
|
--ion-color-warning-contrast-rgb: 31, 41, 55;
|
||||||
--ion-color-warning-shade: #e0ac08;
|
--ion-color-warning-shade: #d97706;
|
||||||
--ion-color-warning-tint: #ffca22;
|
--ion-color-warning-tint: #fbbf24;
|
||||||
|
|
||||||
--ion-color-danger: #eb445a;
|
--ion-color-danger: #dc2626;
|
||||||
--ion-color-danger-rgb: 235, 68, 90;
|
--ion-color-danger-rgb: 220, 38, 38;
|
||||||
--ion-color-danger-contrast: #ffffff;
|
--ion-color-danger-contrast: #ffffff;
|
||||||
--ion-color-danger-contrast-rgb: 255, 255, 255;
|
--ion-color-danger-contrast-rgb: 255, 255, 255;
|
||||||
--ion-color-danger-shade: #cf3c4f;
|
--ion-color-danger-shade: #b91c1c;
|
||||||
--ion-color-danger-tint: #ed576b;
|
--ion-color-danger-tint: #ef4444;
|
||||||
|
|
||||||
--ion-color-dark: #222428;
|
--ion-color-dark: #111827;
|
||||||
--ion-color-dark-rgb: 34, 36, 40;
|
--ion-color-dark-rgb: 17, 24, 39;
|
||||||
--ion-color-dark-contrast: #ffffff;
|
--ion-color-dark-contrast: #ffffff;
|
||||||
--ion-color-dark-contrast-rgb: 255, 255, 255;
|
--ion-color-dark-contrast-rgb: 255, 255, 255;
|
||||||
--ion-color-dark-shade: #1e2023;
|
--ion-color-dark-shade: #030712;
|
||||||
--ion-color-dark-tint: #383a3e;
|
--ion-color-dark-tint: #374151;
|
||||||
|
|
||||||
--ion-color-medium: #92949c;
|
--ion-color-medium: #6b7280;
|
||||||
--ion-color-medium-rgb: 146, 148, 156;
|
--ion-color-medium-rgb: 107, 114, 128;
|
||||||
--ion-color-medium-contrast: #ffffff;
|
--ion-color-medium-contrast: #ffffff;
|
||||||
--ion-color-medium-contrast-rgb: 255, 255, 255;
|
--ion-color-medium-contrast-rgb: 255, 255, 255;
|
||||||
--ion-color-medium-shade: #808289;
|
--ion-color-medium-shade: #4b5563;
|
||||||
--ion-color-medium-tint: #9d9fa6;
|
--ion-color-medium-tint: #9ca3af;
|
||||||
|
|
||||||
--ion-color-light: #f4f5f8;
|
--ion-color-light: #f3f4f6;
|
||||||
--ion-color-light-rgb: 244, 245, 248;
|
--ion-color-light-rgb: 243, 244, 246;
|
||||||
--ion-color-light-contrast: #000000;
|
--ion-color-light-contrast: #111827;
|
||||||
--ion-color-light-contrast-rgb: 0, 0, 0;
|
--ion-color-light-contrast-rgb: 17, 24, 39;
|
||||||
--ion-color-light-shade: #d7d8da;
|
--ion-color-light-shade: #e5e7eb;
|
||||||
--ion-color-light-tint: #f5f6f9;
|
--ion-color-light-tint: #f9fafb;
|
||||||
|
|
||||||
|
--ion-background-color: #f6f7fb;
|
||||||
|
--ion-text-color: #111827;
|
||||||
|
--ion-border-color: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background:
|
||||||
|
radial-gradient(
|
||||||
|
circle at top left,
|
||||||
|
rgba(168, 85, 247, 0.12),
|
||||||
|
transparent 34%
|
||||||
|
),
|
||||||
|
linear-gradient(180deg, #fbf7ff 0%, #f2ecfb 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
ion-toolbar {
|
||||||
|
--background: #ffffff;
|
||||||
|
--color: var(--ion-color-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono-nums {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-shell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-card,
|
||||||
|
.habit-card,
|
||||||
|
.stats-card,
|
||||||
|
.empty-state-card,
|
||||||
|
.inline-message,
|
||||||
|
.auth-card {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #ebeef4;
|
||||||
|
border-radius: 24px;
|
||||||
|
box-shadow: 0 8px 24px rgba(17, 24, 39, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-card {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-card.compact {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--ion-color-primary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-card h1,
|
||||||
|
.auth-hero h1,
|
||||||
|
.empty-state-card h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.9rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
color: var(--ion-color-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-card p,
|
||||||
|
.auth-hero p,
|
||||||
|
.inline-message p,
|
||||||
|
.empty-state-card p,
|
||||||
|
.stats-card p,
|
||||||
|
.habit-copy p {
|
||||||
|
color: var(--ion-color-medium);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-card {
|
||||||
|
padding: 18px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-card-main {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-color-pill {
|
||||||
|
width: 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-copy {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-copy h3,
|
||||||
|
.stats-card h3 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 1.08rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ion-color-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-metrics-row,
|
||||||
|
.stats-card-footer,
|
||||||
|
.stats-card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-metrics-row span,
|
||||||
|
.stats-card-footer span,
|
||||||
|
.stats-card-header p {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--ion-color-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-metrics-row ion-icon {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-card,
|
||||||
|
.inline-message {
|
||||||
|
padding: 28px 22px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-icon {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
margin: 0 auto 14px;
|
||||||
|
border-radius: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(109, 40, 217, 0.1);
|
||||||
|
color: var(--ion-color-primary);
|
||||||
|
font-size: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-shell {
|
||||||
|
min-height: 100%;
|
||||||
|
padding: 32px 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-hero {
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card ion-card-content {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form,
|
||||||
|
.auth-message-block,
|
||||||
|
.habit-form-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-input-item {
|
||||||
|
--background: #f8fafc;
|
||||||
|
--border-radius: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-feedback {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-divider {
|
||||||
|
position: relative;
|
||||||
|
text-align: center;
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-divider::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 1px;
|
||||||
|
background: rgba(107, 114, 128, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-divider span {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0 12px;
|
||||||
|
background: #ffffff;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--ion-color-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.google-auth-button,
|
||||||
|
.apple-auth-button {
|
||||||
|
--border-radius: 18px;
|
||||||
|
--border-color: rgba(17, 24, 39, 0.08);
|
||||||
|
--background: #ffffff;
|
||||||
|
--background-hover: #ffffff;
|
||||||
|
--background-activated: #f8fafc;
|
||||||
|
--color: var(--ion-color-dark);
|
||||||
|
--box-shadow: none;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apple-auth-button {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-text {
|
||||||
|
color: var(--ion-color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-color-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-color-dot {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 3px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-color-dot.selected {
|
||||||
|
border-color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-filter-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-card {
|
||||||
|
padding: 18px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-streak-badge {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-header ion-toolbar {
|
||||||
|
--background: transparent;
|
||||||
|
--border-color: transparent;
|
||||||
|
--padding-start: 12px;
|
||||||
|
--padding-end: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-content {
|
||||||
|
--background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-shell {
|
||||||
|
padding-top: calc(var(--ion-safe-area-top) + 10px);
|
||||||
|
padding-bottom: 32px;
|
||||||
|
gap: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.soft-icon-button {
|
||||||
|
--background: rgba(255, 255, 255, 0.86);
|
||||||
|
--background-hover: rgba(255, 255, 255, 0.96);
|
||||||
|
--background-activated: rgba(255, 255, 255, 0.96);
|
||||||
|
--border-radius: 999px;
|
||||||
|
--box-shadow: 0 10px 24px rgba(109, 40, 217, 0.08);
|
||||||
|
--color: #17121f;
|
||||||
|
--padding-start: 0;
|
||||||
|
--padding-end: 0;
|
||||||
|
--padding-top: 0;
|
||||||
|
--padding-bottom: 0;
|
||||||
|
width: 46px;
|
||||||
|
min-width: 46px;
|
||||||
|
height: 46px;
|
||||||
|
min-height: 46px;
|
||||||
|
border-radius: 999px;
|
||||||
|
margin-inline-start: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.soft-icon-button::part(native) {
|
||||||
|
width: 46px;
|
||||||
|
min-width: 46px;
|
||||||
|
height: 46px;
|
||||||
|
min-height: 46px;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habits-hero {
|
||||||
|
padding: 6px 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habits-hero-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-kicker {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 0.98rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: rgba(34, 23, 54, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-title-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-title-wrap h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(3rem, 8vw, 4.2rem);
|
||||||
|
line-height: 0.94;
|
||||||
|
letter-spacing: -0.08em;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #110d18;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-title-wrap span {
|
||||||
|
font-size: clamp(2.2rem, 7vw, 3.5rem);
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: -0.08em;
|
||||||
|
font-weight: 700;
|
||||||
|
color: rgba(26, 16, 38, 0.32);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-add-button {
|
||||||
|
--background: rgba(255, 255, 255, 0.86);
|
||||||
|
--background-hover: rgba(255, 255, 255, 0.96);
|
||||||
|
--border-radius: 999px;
|
||||||
|
--box-shadow: 0 14px 30px rgba(109, 40, 217, 0.1);
|
||||||
|
--color: var(--ion-color-primary);
|
||||||
|
--padding-start: 0;
|
||||||
|
--padding-end: 0;
|
||||||
|
--padding-top: 0;
|
||||||
|
--padding-bottom: 0;
|
||||||
|
width: 52px;
|
||||||
|
min-width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
min-height: 52px;
|
||||||
|
border-radius: 999px;
|
||||||
|
margin: 4px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-add-button::part(native) {
|
||||||
|
width: 52px;
|
||||||
|
min-width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
min-height: 52px;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-progress-track {
|
||||||
|
margin-top: 20px;
|
||||||
|
height: 12px;
|
||||||
|
background: rgba(109, 40, 217, 0.12);
|
||||||
|
border-radius: 999px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
min-width: 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(90deg, #5b21b6 0%, #c084fc 100%);
|
||||||
|
position: relative;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-progress-dot {
|
||||||
|
position: absolute;
|
||||||
|
right: 2px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-progress-meta {
|
||||||
|
margin-top: 10px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(43, 31, 58, 0.74);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-routine-panel,
|
||||||
|
.home-summary-card,
|
||||||
|
.home-inline-card,
|
||||||
|
.home-empty-state-card,
|
||||||
|
.habit-card--editorial {
|
||||||
|
background: rgba(255, 255, 255, 0.82);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.72);
|
||||||
|
box-shadow: 0 18px 40px rgba(106, 62, 149, 0.08);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-routine-panel {
|
||||||
|
padding: 18px;
|
||||||
|
border-radius: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-habit-stack {
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-card--editorial {
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 14px 12px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-card--editorial.is-complete {
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-check-button {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
background: rgba(109, 40, 217, 0.08);
|
||||||
|
color: rgba(109, 40, 217, 0.72);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-check-button.is-complete {
|
||||||
|
background: rgba(109, 40, 217, 0.16);
|
||||||
|
color: var(--ion-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-copy-topline {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-copy-topline .habit-color-pill {
|
||||||
|
width: 10px;
|
||||||
|
min-height: 10px;
|
||||||
|
height: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-card-tap-area {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
min-width: 0;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-card--editorial .habit-card-main {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-card--editorial .habit-copy h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.03rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-card--editorial .habit-copy p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: rgba(52, 41, 66, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-card--editorial .habit-metrics-row {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-card--editorial .habit-metrics-row span {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-delete-button {
|
||||||
|
--color: rgba(86, 68, 106, 0.72);
|
||||||
|
--padding-start: 6px;
|
||||||
|
--padding-end: 6px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-card-skeleton {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-card-skeleton ion-skeleton-text {
|
||||||
|
margin: 0;
|
||||||
|
--background: rgba(109, 40, 217, 0.08);
|
||||||
|
--background-rgb: 109, 40, 217;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-skeleton-check,
|
||||||
|
.habit-skeleton-action {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-skeleton-title {
|
||||||
|
width: min(42vw, 160px);
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-skeleton-color-pill {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
min-width: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-skeleton-description {
|
||||||
|
width: min(58vw, 220px);
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-skeleton-metric {
|
||||||
|
width: 84px;
|
||||||
|
height: 13px;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-skeleton-metric.short {
|
||||||
|
width: 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-complete-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-section-label {
|
||||||
|
margin: 0 4px;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: rgba(66, 50, 84, 0.74);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-summary-card {
|
||||||
|
border-radius: 30px;
|
||||||
|
padding: 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-ring {
|
||||||
|
width: 88px;
|
||||||
|
height: 88px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-ring-inner {
|
||||||
|
width: 66px;
|
||||||
|
height: 66px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.96);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #1d1430;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-copy h3 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-copy p {
|
||||||
|
margin: 0;
|
||||||
|
color: rgba(52, 41, 66, 0.7);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-metrics {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px 14px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-metrics span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(42, 30, 56, 0.78);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-empty-state-card {
|
||||||
|
border-radius: 26px;
|
||||||
|
border: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-empty-state-button {
|
||||||
|
--border-radius: 999px;
|
||||||
|
--box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-shell {
|
||||||
|
gap: 20px;
|
||||||
|
padding-bottom: calc(24px + var(--ion-safe-area-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-hero-card {
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 30px;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 16px 38px rgba(71, 45, 108, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-hero-image-wrap {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 16 / 10;
|
||||||
|
background: #e9e1f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-hero-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-hero-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
rgba(17, 24, 39, 0) 0%,
|
||||||
|
rgba(17, 24, 39, 0.3) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-hero-content {
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-hero-content h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.55rem;
|
||||||
|
line-height: 1.15;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
color: var(--ion-color-dark);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-hero-content p {
|
||||||
|
margin: 0;
|
||||||
|
color: rgba(55, 65, 81, 0.76);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-highlight-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-highlight-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(109, 40, 217, 0.1);
|
||||||
|
color: var(--ion-color-primary);
|
||||||
|
font-size: 0.84rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-highlight-chip.subtle {
|
||||||
|
background: rgba(15, 118, 110, 0.1);
|
||||||
|
color: var(--ion-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-highlight-chip ion-icon,
|
||||||
|
.account-highlight-chip svg {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-details-card {
|
||||||
|
gap: 18px;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-info-item {
|
||||||
|
--background: #f7f5fb;
|
||||||
|
--border-radius: 20px;
|
||||||
|
--padding-start: 16px;
|
||||||
|
--padding-end: 16px;
|
||||||
|
--inner-padding-end: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-info-item h2 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ion-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-info-item p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ion-color-dark);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-trust-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 4px 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-trust-icon {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(22, 163, 74, 0.12);
|
||||||
|
color: var(--ion-color-success);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-trust-row h3 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ion-color-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-trust-row p {
|
||||||
|
margin: 0;
|
||||||
|
color: rgba(55, 65, 81, 0.74);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-primary-button,
|
||||||
|
.account-secondary-button {
|
||||||
|
--border-radius: 18px;
|
||||||
|
min-height: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-primary-button {
|
||||||
|
--background: var(--ion-color-primary);
|
||||||
|
--box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-secondary-button {
|
||||||
|
--border-width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 420px) {
|
||||||
|
.home-summary-card {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-hero-image-wrap {
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { StatusBar, Style } from '@capacitor/status-bar';
|
import { StatusBar, Style } from '@capacitor/status-bar';
|
||||||
export { Style };
|
export { Style };
|
||||||
``
|
|
||||||
const _origin = (() => {
|
const _origin = (() => {
|
||||||
try { if (document.referrer) return new URL(document.referrer).origin; } catch {}
|
try { if (document.referrer) return new URL(document.referrer).origin; } catch {}
|
||||||
return window.location.origin;
|
return window.location.origin;
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"esModuleInterop": false,
|
"esModuleInterop": false,
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
"strict": false,
|
"strict": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "Node",
|
"moduleResolution": "Node",
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
ext {
|
|
||||||
minSdkVersion = 24
|
|
||||||
compileSdkVersion = 36
|
|
||||||
targetSdkVersion = 36
|
|
||||||
androidxActivityVersion = '1.11.0'
|
|
||||||
androidxAppCompatVersion = '1.7.1'
|
|
||||||
androidxCoordinatorLayoutVersion = '1.3.0'
|
|
||||||
androidxCoreVersion = '1.17.0'
|
|
||||||
androidxFragmentVersion = '1.8.9'
|
|
||||||
coreSplashScreenVersion = '1.2.0'
|
|
||||||
androidxWebkitVersion = '1.14.0'
|
|
||||||
junitVersion = '4.13.2'
|
|
||||||
androidxJunitVersion = '1.3.0'
|
|
||||||
androidxEspressoCoreVersion = '3.7.0'
|
|
||||||
cordovaAndroidVersion = '14.0.1'
|
|
||||||
rgcfaIncludeGoogle = true
|
|
||||||
androidxCredentialsVersion = '1.3.0'
|
|
||||||
}
|
|
||||||
+2
-3
@@ -5,12 +5,11 @@ const injectRouterBasename: Plugin = {
|
|||||||
name: 'inject-router-basename',
|
name: 'inject-router-basename',
|
||||||
enforce: 'pre',
|
enforce: 'pre',
|
||||||
transform(code, id) {
|
transform(code, id) {
|
||||||
if (!/\.[jt]sx$/.test(id)) return null;
|
if (!/\.[jt]sx$/.test(id)) return;
|
||||||
const next = code.replace(
|
return code.replace(
|
||||||
/<IonReactRouter(?![^>]*basename)([^>]*)>/g,
|
/<IonReactRouter(?![^>]*basename)([^>]*)>/g,
|
||||||
'<IonReactRouter$1 basename={import.meta.env.BASE_URL}>',
|
'<IonReactRouter$1 basename={import.meta.env.BASE_URL}>',
|
||||||
);
|
);
|
||||||
return next === code ? null : next;
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user