commit 17c0526ed68ac78bdf04a643152c6cebd8cbfab3 Author: Narayanan Madaswamy Date: Sun Aug 16 20:13:21 2026 +0530 V1 - Personal Account and Budgeting Done Personal Account and Budgeting diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..df49bf0 Binary files /dev/null and b/.DS_Store differ diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..d7d3c2b --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "java", + "name": "KifiApiApplication", + "request": "launch", + "mainClass": "com.kifi.api.KifiApiApplication", + "projectName": "kifi-api" + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..d53ecaf --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "java.compile.nullAnalysis.mode": "automatic", + "java.configuration.updateBuildConfiguration": "automatic" +} \ No newline at end of file diff --git a/kifi-api/.gitattributes b/kifi-api/.gitattributes new file mode 100644 index 0000000..3b41682 --- /dev/null +++ b/kifi-api/.gitattributes @@ -0,0 +1,2 @@ +/mvnw text eol=lf +*.cmd text eol=crlf diff --git a/kifi-api/.gitignore b/kifi-api/.gitignore new file mode 100644 index 0000000..667aaef --- /dev/null +++ b/kifi-api/.gitignore @@ -0,0 +1,33 @@ +HELP.md +target/ +.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ diff --git a/kifi-api/.mvn/wrapper/maven-wrapper.properties b/kifi-api/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..216df05 --- /dev/null +++ b/kifi-api/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,3 @@ +wrapperVersion=3.3.4 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zip diff --git a/kifi-api/Dockerfile b/kifi-api/Dockerfile new file mode 100644 index 0000000..f7210ae --- /dev/null +++ b/kifi-api/Dockerfile @@ -0,0 +1,13 @@ +FROM eclipse-temurin:21-jdk-alpine AS builder +WORKDIR /app +COPY .mvn/ .mvn +COPY mvnw pom.xml ./ +RUN ./mvnw dependency:go-offline +COPY src ./src +RUN ./mvnw clean package -DskipTests + +FROM eclipse-temurin:21-jre-alpine +WORKDIR /app +COPY --from=builder /app/target/*.jar app.jar +EXPOSE 8080 +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/kifi-api/Kifi-API.postman_collection.json b/kifi-api/Kifi-API.postman_collection.json new file mode 100644 index 0000000..1b79817 --- /dev/null +++ b/kifi-api/Kifi-API.postman_collection.json @@ -0,0 +1,360 @@ +{ + "info": { + "name": "Kifi API", + "description": "Postman Collection for Kifi API", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "variable": [ + { + "key": "base_url", + "value": "https://app.technobeesolutions.in/api/kifi", + "type": "string" + }, + { + "key": "token", + "value": "YOUR_JWT_TOKEN", + "type": "string" + } + ], + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{token}}", + "type": "string" + } + ] + }, + "item": [ + { + "name": "Auth", + "item": [ + { + "name": "Signup", + "request": { + "auth": { "type": "noauth" }, + "method": "POST", + "header": [ { "key": "Content-Type", "value": "application/json" } ], + "body": { + "mode": "raw", + "raw": "{\n \"phone\": \"+1234567890\",\n \"password\": \"securepassword\"\n}" + }, + "url": { + "raw": "{{base_url}}/auth/signup", + "host": [ "{{base_url}}" ], + "path": [ "auth", "signup" ] + } + } + }, + { + "name": "Verify OTP", + "request": { + "auth": { "type": "noauth" }, + "method": "POST", + "header": [ { "key": "Content-Type", "value": "application/json" } ], + "body": { + "mode": "raw", + "raw": "{\n \"phone\": \"+1234567890\",\n \"otp\": \"123456\"\n}" + }, + "url": { + "raw": "{{base_url}}/auth/verify-otp", + "host": [ "{{base_url}}" ], + "path": [ "auth", "verify-otp" ] + } + } + }, + { + "name": "Login", + "request": { + "auth": { "type": "noauth" }, + "method": "POST", + "header": [ { "key": "Content-Type", "value": "application/json" } ], + "body": { + "mode": "raw", + "raw": "{\n \"phone\": \"+1234567890\",\n \"password\": \"securepassword\"\n}" + }, + "url": { + "raw": "{{base_url}}/auth/login", + "host": [ "{{base_url}}" ], + "path": [ "auth", "login" ] + } + } + } + ] + }, + { + "name": "Wallets", + "item": [ + { + "name": "Get Wallets", + "request": { + "method": "GET", + "url": { + "raw": "{{base_url}}/wallets", + "host": [ "{{base_url}}" ], + "path": [ "wallets" ] + } + } + }, + { + "name": "Create Wallet", + "request": { + "method": "POST", + "header": [ { "key": "Content-Type", "value": "application/json" } ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"My Family Wallet\"\n}" + }, + "url": { + "raw": "{{base_url}}/wallets", + "host": [ "{{base_url}}" ], + "path": [ "wallets" ] + } + } + }, + { + "name": "Invite User", + "request": { + "method": "POST", + "header": [ { "key": "Content-Type", "value": "application/json" } ], + "body": { + "mode": "raw", + "raw": "{\n \"userPhone\": \"+1987654321\"\n}" + }, + "url": { + "raw": "{{base_url}}/wallets/1/invite", + "host": [ "{{base_url}}" ], + "path": [ "wallets", "1", "invite" ] + } + } + } + ] + }, + { + "name": "Transactions", + "item": [ + { + "name": "Get Transactions", + "request": { + "method": "GET", + "url": { + "raw": "{{base_url}}/transactions", + "host": [ "{{base_url}}" ], + "path": [ "transactions" ] + } + } + }, + { + "name": "Add Transaction", + "request": { + "method": "POST", + "header": [ { "key": "Content-Type", "value": "application/json" } ], + "body": { + "mode": "raw", + "raw": "{\n \"amount\": 50.0,\n \"type\": \"EXPENSE\",\n \"categoryId\": 1,\n \"merchantId\": 1,\n \"date\": \"2023-10-01T10:00:00\"\n}" + }, + "url": { + "raw": "{{base_url}}/transactions", + "host": [ "{{base_url}}" ], + "path": [ "transactions" ] + } + } + }, + { + "name": "Update Transaction", + "request": { + "method": "PUT", + "header": [ { "key": "Content-Type", "value": "application/json" } ], + "body": { + "mode": "raw", + "raw": "{\n \"amount\": 75.0,\n \"type\": \"EXPENSE\",\n \"categoryId\": 1,\n \"merchantId\": 1,\n \"date\": \"2023-10-01T10:00:00\"\n}" + }, + "url": { + "raw": "{{base_url}}/transactions/1", + "host": [ "{{base_url}}" ], + "path": [ "transactions", "1" ] + } + } + }, + { + "name": "Delete Transaction", + "request": { + "method": "DELETE", + "url": { + "raw": "{{base_url}}/transactions/1", + "host": [ "{{base_url}}" ], + "path": [ "transactions", "1" ] + } + } + } + ] + }, + { + "name": "Budgets", + "item": [ + { + "name": "Get Budgets", + "request": { + "method": "GET", + "url": { + "raw": "{{base_url}}/budgets", + "host": [ "{{base_url}}" ], + "path": [ "budgets" ] + } + } + }, + { + "name": "Add/Update Budget", + "request": { + "method": "POST", + "header": [ { "key": "Content-Type", "value": "application/json" } ], + "body": { + "mode": "raw", + "raw": "{\n \"categoryId\": 1,\n \"limit\": 500.0,\n \"month\": 10,\n \"year\": 2023\n}" + }, + "url": { + "raw": "{{base_url}}/budgets", + "host": [ "{{base_url}}" ], + "path": [ "budgets" ] + } + } + }, + { + "name": "Delete Budget", + "request": { + "method": "DELETE", + "url": { + "raw": "{{base_url}}/budgets/1", + "host": [ "{{base_url}}" ], + "path": [ "budgets", "1" ] + } + } + } + ] + }, + { + "name": "Categories", + "item": [ + { + "name": "Get Categories", + "request": { + "method": "GET", + "url": { + "raw": "{{base_url}}/categories", + "host": [ "{{base_url}}" ], + "path": [ "categories" ] + } + } + }, + { + "name": "Add Category", + "request": { + "method": "POST", + "header": [ { "key": "Content-Type", "value": "application/json" } ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Groceries\",\n \"icon\": \"shopping_cart\",\n \"color\": \"#FF0000\"\n}" + }, + "url": { + "raw": "{{base_url}}/categories", + "host": [ "{{base_url}}" ], + "path": [ "categories" ] + } + } + } + ] + }, + { + "name": "Merchants", + "item": [ + { + "name": "Get Merchants", + "request": { + "method": "GET", + "url": { + "raw": "{{base_url}}/merchants", + "host": [ "{{base_url}}" ], + "path": [ "merchants" ] + } + } + }, + { + "name": "Add Merchant", + "request": { + "method": "POST", + "header": [ { "key": "Content-Type", "value": "application/json" } ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Walmart\"\n}" + }, + "url": { + "raw": "{{base_url}}/merchants", + "host": [ "{{base_url}}" ], + "path": [ "merchants" ] + } + } + } + ] + }, + { + "name": "Recurring Transactions", + "item": [ + { + "name": "Get Recurring Transactions", + "request": { + "method": "GET", + "url": { + "raw": "{{base_url}}/recurring-transactions", + "host": [ "{{base_url}}" ], + "path": [ "recurring-transactions" ] + } + } + }, + { + "name": "Add Recurring Transaction", + "request": { + "method": "POST", + "header": [ { "key": "Content-Type", "value": "application/json" } ], + "body": { + "mode": "raw", + "raw": "{\n \"amount\": 15.99,\n \"type\": \"EXPENSE\",\n \"categoryId\": 1,\n \"frequency\": \"MONTHLY\",\n \"nextDate\": \"2023-11-01T10:00:00\"\n}" + }, + "url": { + "raw": "{{base_url}}/recurring-transactions", + "host": [ "{{base_url}}" ], + "path": [ "recurring-transactions" ] + } + } + }, + { + "name": "Delete Recurring Transaction", + "request": { + "method": "DELETE", + "url": { + "raw": "{{base_url}}/recurring-transactions/1", + "host": [ "{{base_url}}" ], + "path": [ "recurring-transactions", "1" ] + } + } + } + ] + }, + { + "name": "Reports", + "item": [ + { + "name": "Export CSV", + "request": { + "method": "GET", + "url": { + "raw": "{{base_url}}/reports/export", + "host": [ "{{base_url}}" ], + "path": [ "reports", "export" ] + } + } + } + ] + } + ] +} diff --git a/kifi-api/build_n_push.sh b/kifi-api/build_n_push.sh new file mode 100755 index 0000000..70dd073 --- /dev/null +++ b/kifi-api/build_n_push.sh @@ -0,0 +1,20 @@ +#!/bin/bash + +# Configuration +REGISTRY="hub.technobeesolutions.in" +USERNAME="technobee_admin" +PASSWORD='M@tr!x#149@dm!N' +IMAGE_NAME="kifi-api" +TAG="latest" +FULL_IMAGE_NAME="$REGISTRY/$IMAGE_NAME:$TAG" + +# Stop on any error +set -e + +echo "Logging into Docker registry: $REGISTRY..." +echo "$PASSWORD" | docker login "$REGISTRY" -u "$USERNAME" --password-stdin + +echo "Building and pushing Docker image for linux/amd64: $FULL_IMAGE_NAME..." +docker buildx build --platform linux/amd64 -t "$FULL_IMAGE_NAME" --push . + +echo "Done! Image built and pushed successfully." diff --git a/kifi-api/docker-compose.yml b/kifi-api/docker-compose.yml new file mode 100644 index 0000000..1f2d319 --- /dev/null +++ b/kifi-api/docker-compose.yml @@ -0,0 +1,30 @@ +services: + api: + image: hub.technobeesolutions.in/kifi-api:latest + container_name: kifi-api + ports: + - "8056:8080" + environment: + - SPRING_R2DBC_URL=r2dbc:postgresql://host.docker.internal:5432/kifi + - SPRING_R2DBC_USERNAME=postgres + - SPRING_R2DBC_PASSWORD=M@triXPostgr3s@6202 + - SPRING_DATA_REDIS_HOST=redis + - SPRING_DATA_REDIS_PORT=6379 + - SPRING_DATA_REDIS_PASSWORD=M@triXR3d1s@6202 + - SPRING_MAIL_HOST=smtp.gmail.com + - SPRING_MAIL_PORT=587 + - SPRING_MAIL_USERNAME=technobeesolutions@gmail.com + - SPRING_MAIL_PASSWORD=lrideibfakickldg + - MINIO_SERVICE_URL=http://minio-service:1500 + - MINIO_SERVICE_BUCKET=kifi + extra_hosts: + - "host.docker.internal:host-gateway" + networks: + commons-network: + ipv4_address: 172.19.0.224 + restart: unless-stopped + +networks: + commons-network: + external: true + name: commons_commons-network diff --git a/kifi-api/mvnw b/kifi-api/mvnw new file mode 100755 index 0000000..bd8896b --- /dev/null +++ b/kifi-api/mvnw @@ -0,0 +1,295 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.4 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/kifi-api/mvnw.cmd b/kifi-api/mvnw.cmd new file mode 100644 index 0000000..92450f9 --- /dev/null +++ b/kifi-api/mvnw.cmd @@ -0,0 +1,189 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.4 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' + +$MAVEN_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/kifi-api/pom.xml b/kifi-api/pom.xml new file mode 100644 index 0000000..aadcbce --- /dev/null +++ b/kifi-api/pom.xml @@ -0,0 +1,171 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 4.1.0 + + + com.kifi + kifi-api + 0.0.1-SNAPSHOT + kifi-api + Kifi Backend API + + + + + + + + + + + + + + + 21 + + + + org.springframework.boot + spring-boot-starter-data-r2dbc + + + org.springframework.boot + spring-boot-starter-data-redis-reactive + + + org.springframework.boot + spring-boot-starter-mail + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-webflux + + + + org.postgresql + postgresql + runtime + + + org.postgresql + r2dbc-postgresql + runtime + + + org.projectlombok + lombok + true + + + org.springframework.boot + spring-boot-starter-data-r2dbc-test + test + + + org.springframework.boot + spring-boot-starter-data-redis-reactive-test + test + + + org.springframework.boot + spring-boot-starter-mail-test + test + + + org.springframework.boot + spring-boot-starter-security-test + test + + + org.springframework.boot + spring-boot-starter-validation-test + test + + + org.springframework.boot + spring-boot-starter-webflux-test + test + + + io.jsonwebtoken + jjwt-api + 0.12.5 + + + io.jsonwebtoken + jjwt-impl + 0.12.5 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.12.5 + + + org.apache.commons + commons-csv + 1.10.0 + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + org.apache.maven.plugins + maven-compiler-plugin + + + default-compile + compile + + compile + + + + + org.projectlombok + lombok + + + + + + default-testCompile + test-compile + + testCompile + + + + + org.projectlombok + lombok + + + + + + + + + + diff --git a/kifi-api/src/main/java/com/kifi/api/KifiApiApplication.java b/kifi-api/src/main/java/com/kifi/api/KifiApiApplication.java new file mode 100644 index 0000000..d7a5cc3 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/KifiApiApplication.java @@ -0,0 +1,16 @@ +package com.kifi.api; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +import org.springframework.scheduling.annotation.EnableScheduling; + +@SpringBootApplication +@EnableScheduling +public class KifiApiApplication { + + public static void main(String[] args) { + SpringApplication.run(KifiApiApplication.class, args); + } + +} diff --git a/kifi-api/src/main/java/com/kifi/api/config/SecurityConfig.java b/kifi-api/src/main/java/com/kifi/api/config/SecurityConfig.java new file mode 100644 index 0000000..d430caf --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/config/SecurityConfig.java @@ -0,0 +1,54 @@ +package com.kifi.api.config; + +import com.kifi.api.security.AuthenticationManager; +import com.kifi.api.security.SecurityContextRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; +import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.server.SecurityWebFilterChain; +import reactor.core.publisher.Mono; + +@Configuration +@EnableWebFluxSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final AuthenticationManager authenticationManager; + private final SecurityContextRepository securityContextRepository; + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) { + return http + .exceptionHandling(exceptionHandlingSpec -> exceptionHandlingSpec + .authenticationEntryPoint((swe, e) -> + Mono.fromRunnable(() -> swe.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED)) + ) + .accessDeniedHandler((swe, e) -> + Mono.fromRunnable(() -> swe.getResponse().setStatusCode(HttpStatus.FORBIDDEN)) + ) + ) + .csrf(ServerHttpSecurity.CsrfSpec::disable) + .formLogin(ServerHttpSecurity.FormLoginSpec::disable) + .httpBasic(ServerHttpSecurity.HttpBasicSpec::disable) + .authenticationManager(authenticationManager) + .securityContextRepository(securityContextRepository) + .authorizeExchange(exchange -> exchange + .pathMatchers(HttpMethod.OPTIONS).permitAll() + .pathMatchers("/api/kifi/auth/**").permitAll() + .pathMatchers("/api/kifi/health/**").permitAll() + .anyExchange().authenticated() + ) + .build(); + } +} diff --git a/kifi-api/src/main/java/com/kifi/api/config/WebClientConfig.java b/kifi-api/src/main/java/com/kifi/api/config/WebClientConfig.java new file mode 100644 index 0000000..4cee9ce --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/config/WebClientConfig.java @@ -0,0 +1,14 @@ +package com.kifi.api.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.reactive.function.client.WebClient; + +@Configuration +public class WebClientConfig { + + @Bean + public WebClient.Builder webClientBuilder() { + return WebClient.builder(); + } +} diff --git a/kifi-api/src/main/java/com/kifi/api/controller/AuthController.java b/kifi-api/src/main/java/com/kifi/api/controller/AuthController.java new file mode 100644 index 0000000..930adee --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/controller/AuthController.java @@ -0,0 +1,63 @@ +package com.kifi.api.controller; + +import com.kifi.api.dto.AuthRequest; +import com.kifi.api.dto.OtpVerificationRequest; +import com.kifi.api.dto.ResetPasswordRequest; +import com.kifi.api.service.AuthService; +import com.kifi.api.service.CryptoService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import reactor.core.publisher.Mono; + +import java.util.Map; + +@RestController +@RequestMapping("/api/kifi/auth") +@RequiredArgsConstructor +public class AuthController { + + private final AuthService authService; + private final CryptoService cryptoService; + + @GetMapping("/public-key") + public Mono> getPublicKey() { + return Mono.just(ResponseEntity.ok().body((Object) Map.of("publicKey", cryptoService.getPublicKeyBase64()))); + } + + @PostMapping("/signup") + public Mono> signup(@RequestBody AuthRequest request) { + return authService.signup(request) + .map(msg -> ResponseEntity.ok().body((Object) Map.of("message", msg))) + .onErrorResume(e -> Mono.just(ResponseEntity.badRequest().body(Map.of("error", e.getMessage())))); + } + + @PostMapping("/verify-otp") + public Mono> verifyOtp(@RequestBody OtpVerificationRequest request) { + return authService.verifyOtp(request.getEmail(), request.getOtp()) + .map(response -> ResponseEntity.ok().body((Object) response)) + .onErrorResume(e -> Mono.just(ResponseEntity.badRequest().body(Map.of("error", e.getMessage())))); + } + + @PostMapping("/login") + public Mono> login(@RequestBody AuthRequest request) { + return authService.login(request) + .map(response -> ResponseEntity.ok().body((Object) response)) + .onErrorResume(e -> Mono.just(ResponseEntity.status(401).body(Map.of("error", e.getMessage())))); + } + + @PostMapping("/forgot-password") + public Mono> forgotPassword(@RequestBody Map request) { + String email = request.get("email"); + return authService.forgotPassword(email) + .map(msg -> ResponseEntity.ok().body((Object) Map.of("message", msg))) + .onErrorResume(e -> Mono.just(ResponseEntity.badRequest().body(Map.of("error", e.getMessage())))); + } + + @PostMapping("/reset-password") + public Mono> resetPassword(@RequestBody ResetPasswordRequest request) { + return authService.resetPassword(request.getEmail(), request.getOtp(), request.getNewPassword()) + .map(msg -> ResponseEntity.ok().body((Object) Map.of("message", msg))) + .onErrorResume(e -> Mono.just(ResponseEntity.badRequest().body(Map.of("error", e.getMessage())))); + } +} diff --git a/kifi-api/src/main/java/com/kifi/api/controller/BudgetController.java b/kifi-api/src/main/java/com/kifi/api/controller/BudgetController.java new file mode 100644 index 0000000..04dcfda --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/controller/BudgetController.java @@ -0,0 +1,42 @@ +package com.kifi.api.controller; + +import com.kifi.api.entity.Budget; +import com.kifi.api.dto.BudgetSummary; +import com.kifi.api.service.BudgetService; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.*; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import java.util.List; + +@RestController +@RequestMapping("/api/kifi/budgets") +@RequiredArgsConstructor +public class BudgetController { + private final BudgetService budgetService; + + @GetMapping + public Mono> getBudgets(Authentication authentication) { + Long userId = Long.valueOf(authentication.getDetails().toString()); + return budgetService.getBudgets(userId).collectList(); + } + + @GetMapping("/summary") + public Mono> getBudgetSummaries(Authentication authentication) { + Long userId = Long.valueOf(authentication.getDetails().toString()); + return budgetService.getBudgetSummaries(userId).collectList(); + } + + @PostMapping + public Mono addOrUpdateBudget(Authentication authentication, @RequestBody Budget budget) { + Long userId = Long.valueOf(authentication.getDetails().toString()); + return budgetService.addOrUpdateBudget(userId, budget); + } + + @DeleteMapping("/{id}") + public Mono deleteBudget(@PathVariable Long id) { + return budgetService.deleteBudget(id); + } +} diff --git a/kifi-api/src/main/java/com/kifi/api/controller/CategoryController.java b/kifi-api/src/main/java/com/kifi/api/controller/CategoryController.java new file mode 100644 index 0000000..20413c6 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/controller/CategoryController.java @@ -0,0 +1,30 @@ +package com.kifi.api.controller; + +import com.kifi.api.entity.Category; +import com.kifi.api.service.CategoryService; +import lombok.RequiredArgsConstructor; + +import java.util.List; + +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; +import reactor.core.publisher.Mono; + +@RestController +@RequestMapping("/api/kifi/categories") +@RequiredArgsConstructor +public class CategoryController { + private final CategoryService categoryService; + + @GetMapping + public Mono> getCategories(Authentication authentication) { + Long userId = Long.valueOf(authentication.getDetails().toString()); + return categoryService.getCategories(userId).collectList(); + } + + @PostMapping + public Mono addCategory(Authentication authentication, @RequestBody Category category) { + Long userId = Long.valueOf(authentication.getDetails().toString()); + return categoryService.addCategory(userId, category); + } +} diff --git a/kifi-api/src/main/java/com/kifi/api/controller/HealthController.java b/kifi-api/src/main/java/com/kifi/api/controller/HealthController.java new file mode 100644 index 0000000..ea02313 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/controller/HealthController.java @@ -0,0 +1,18 @@ +package com.kifi.api.controller; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import reactor.core.publisher.Mono; + +import java.util.Map; + +@RestController +@RequestMapping("/api/kifi/health") +public class HealthController { + + @GetMapping + public Mono> healthCheck() { + return Mono.just(Map.of("status", "UP", "message", "Kifi API is running.")); + } +} diff --git a/kifi-api/src/main/java/com/kifi/api/controller/RecurringTransactionController.java b/kifi-api/src/main/java/com/kifi/api/controller/RecurringTransactionController.java new file mode 100644 index 0000000..b50537b --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/controller/RecurringTransactionController.java @@ -0,0 +1,43 @@ +package com.kifi.api.controller; + +import com.kifi.api.entity.RecurringTransaction; +import com.kifi.api.service.RecurringTransactionService; +import lombok.RequiredArgsConstructor; + +import java.util.List; + +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; +import java.util.List; +import reactor.core.publisher.Mono; + +@RestController +@RequestMapping("/api/kifi/recurring-transactions") +@RequiredArgsConstructor +public class RecurringTransactionController { + + private final RecurringTransactionService service; + + @GetMapping + public Mono> getRecurringTransactions(Authentication authentication) { + Long userId = Long.valueOf(authentication.getDetails().toString()); + return service.getRecurringTransactions(userId).collectList(); + } + + @PostMapping + public Mono addRecurringTransaction(Authentication authentication, @RequestBody RecurringTransaction transaction) { + Long userId = Long.valueOf(authentication.getDetails().toString()); + return service.addRecurringTransaction(userId, transaction); + } + + @PutMapping("/{id}") + public Mono updateRecurringTransaction(@PathVariable Long id, Authentication authentication, @RequestBody RecurringTransaction transaction) { + Long userId = Long.valueOf(authentication.getDetails().toString()); + return service.updateRecurringTransaction(id, userId, transaction); + } + + @DeleteMapping("/{id}") + public Mono deleteRecurringTransaction(@PathVariable Long id) { + return service.deleteRecurringTransaction(id); + } +} diff --git a/kifi-api/src/main/java/com/kifi/api/controller/ReportController.java b/kifi-api/src/main/java/com/kifi/api/controller/ReportController.java new file mode 100644 index 0000000..3591fb0 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/controller/ReportController.java @@ -0,0 +1,29 @@ +package com.kifi.api.controller; + +import com.kifi.api.service.ReportService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import reactor.core.publisher.Mono; + +@RestController +@RequestMapping("/api/kifi/reports") +@RequiredArgsConstructor +public class ReportController { + private final ReportService reportService; + + @GetMapping(value = "/export", produces = "text/csv") + public Mono> exportTransactions(Authentication authentication) { + Long userId = Long.valueOf(authentication.getDetails().toString()); + return reportService.exportTransactionsToCsv(userId) + .map(bytes -> ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"transactions.csv\"") + .contentType(MediaType.parseMediaType("text/csv")) + .body(bytes)); + } +} diff --git a/kifi-api/src/main/java/com/kifi/api/controller/TransactionController.java b/kifi-api/src/main/java/com/kifi/api/controller/TransactionController.java new file mode 100644 index 0000000..ae29267 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/controller/TransactionController.java @@ -0,0 +1,101 @@ +package com.kifi.api.controller; + +import com.kifi.api.entity.Transaction; +import com.kifi.api.service.TransactionService; + +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; +import java.util.List; +import reactor.core.publisher.Mono; + +@RestController +@RequestMapping("/api/kifi/transactions") +@RequiredArgsConstructor +public class TransactionController { + private final TransactionService transactionService; + + @GetMapping + public Mono> getTransactions(Authentication authentication) { + Long userId = Long.valueOf(authentication.getDetails().toString()); + return transactionService.getTransactions(userId).collectList(); + } + + @GetMapping("/search") + public Mono> searchTransactions( + Authentication authentication, + @RequestParam(required = false) String type, + @RequestParam(required = false) Long walletId, + @RequestParam(required = false) Long categoryId, + @RequestParam(required = false) @org.springframework.format.annotation.DateTimeFormat(iso = org.springframework.format.annotation.DateTimeFormat.ISO.DATE) java.time.LocalDate startDate, + @RequestParam(required = false) @org.springframework.format.annotation.DateTimeFormat(iso = org.springframework.format.annotation.DateTimeFormat.ISO.DATE) java.time.LocalDate endDate, + @RequestParam(required = false) String search, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + Long userId = Long.valueOf(authentication.getDetails().toString()); + Pageable pageable = PageRequest.of(page, size); + return transactionService.searchTransactions(userId, type, walletId, categoryId, startDate, endDate, search, pageable); + } + + @PostMapping + public Mono addTransaction(Authentication authentication, @RequestBody Transaction transaction) { + Long userId = Long.valueOf(authentication.getDetails().toString()); + return transactionService.addTransaction(userId, transaction); + } + + @PutMapping("/{id}") + public Mono updateTransaction(Authentication authentication, @PathVariable Long id, @RequestBody Transaction transaction) { + Long userId = Long.valueOf(authentication.getDetails().toString()); + return transactionService.updateTransaction(id, userId, transaction); + } + + @DeleteMapping("/{id}") + public Mono deleteTransaction(@PathVariable Long id) { + return transactionService.deleteTransaction(id); + } + + @PostMapping(value = "/{id}/attachments", consumes = org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE) + public Mono addAttachment(@PathVariable Long id, @RequestPart("file") org.springframework.http.codec.multipart.FilePart filePart) { + return org.springframework.core.io.buffer.DataBufferUtils.join(filePart.content()) + .flatMap(dataBuffer -> { + byte[] bytes = new byte[dataBuffer.readableByteCount()]; + dataBuffer.read(bytes); + org.springframework.core.io.buffer.DataBufferUtils.release(dataBuffer); + String base64Content = java.util.Base64.getEncoder().encodeToString(bytes); + String fileName = filePart.filename(); + String contentType = filePart.headers().getContentType() != null ? filePart.headers().getContentType().toString() : "application/octet-stream"; + return transactionService.addAttachment(id, fileName, contentType, base64Content); + }); + } + + @GetMapping("/{id}/attachments/{attachmentId}/content") + public Mono> downloadAttachment(@PathVariable Long id, @PathVariable Long attachmentId) { + return transactionService.downloadAttachment(attachmentId) + .map(response -> { + byte[] decodedBytes = java.util.Base64.getDecoder().decode(response.getBase64Content()); + return org.springframework.http.ResponseEntity.ok() + .header(org.springframework.http.HttpHeaders.CONTENT_TYPE, "image/jpeg") // Ideally dynamic based on db + .body(decodedBytes); + }); + } + + @DeleteMapping("/attachments/{attachmentId}") + public Mono deleteAttachment(@PathVariable Long attachmentId) { + return transactionService.deleteAttachment(attachmentId); + } + + @PutMapping("/{id}/close-investment") + public Mono closeInvestment(Authentication authentication, @PathVariable Long id, @RequestBody java.util.Map payload) { + Long userId = Long.valueOf(authentication.getDetails().toString()); + java.math.BigDecimal maturityAmount = new java.math.BigDecimal(payload.get("maturityAmount").toString()); + java.time.LocalDate closingDate = payload.get("closingDate") != null ? java.time.LocalDate.parse(payload.get("closingDate").toString()) : java.time.LocalDate.now(); + Long toWalletId = payload.get("toWalletId") != null ? Long.valueOf(payload.get("toWalletId").toString()) : null; + return transactionService.closeInvestment(id, userId, maturityAmount, closingDate, toWalletId); + } +} diff --git a/kifi-api/src/main/java/com/kifi/api/controller/WalletController.java b/kifi-api/src/main/java/com/kifi/api/controller/WalletController.java new file mode 100644 index 0000000..583d688 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/controller/WalletController.java @@ -0,0 +1,104 @@ +package com.kifi.api.controller; + +import com.kifi.api.entity.UserWallet; +import com.kifi.api.entity.Wallet; +import com.kifi.api.entity.WalletInvitation; +import com.kifi.api.service.WalletService; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import java.util.List; + +@RestController +@RequestMapping("/api/kifi/wallets") +@RequiredArgsConstructor +public class WalletController { + + private final WalletService walletService; + + @GetMapping + public Mono> getWallets(Authentication authentication) { + Long userId = Long.valueOf(authentication.getDetails().toString()); + return walletService.getWalletsForUser(userId).collectList(); + } + + @PostMapping + public Mono createWallet(@RequestBody CreateWalletRequest request, Authentication authentication) { + Long userId = Long.valueOf(authentication.getDetails().toString()); + return walletService.createWallet(userId, request.getName(), request.getNature(), request.getIcon(), request.getColor(), request.getCurrency(), request.getInitialBalance()); + } + + @PutMapping("/{walletId}") + public Mono editWallet(@PathVariable Long walletId, @RequestBody CreateWalletRequest request, Authentication authentication) { + Long userId = Long.valueOf(authentication.getDetails().toString()); + return walletService.editWallet(walletId, userId, request.getName(), request.getNature(), request.getIcon(), request.getColor(), request.getCurrency()); + } + + @DeleteMapping("/{walletId}") + public Mono deleteWallet(@PathVariable Long walletId, Authentication authentication) { + Long userId = Long.valueOf(authentication.getDetails().toString()); + return walletService.deleteWallet(walletId, userId); + } + + @PostMapping("/{walletId}/invite") + public Mono inviteUser(@PathVariable Long walletId, @RequestBody InviteRequest request, Authentication authentication) { + Long inviterId = Long.valueOf(authentication.getDetails().toString()); + return walletService.inviteUserByEmail(inviterId, walletId, request.getEmail()); + } + + @GetMapping("/invitations") + public Mono> getInvitations(Authentication authentication) { + String email = authentication.getName(); + return walletService.getPendingInvitations(email).collectList(); + } + + @PostMapping("/invitations/{id}/accept") + public Mono acceptInvitation(@PathVariable Long id, Authentication authentication) { + String email = authentication.getName(); + Long userId = Long.valueOf(authentication.getDetails().toString()); + return walletService.respondToInvitation(id, email, userId, true); + } + + @PostMapping("/invitations/{id}/reject") + public Mono rejectInvitation(@PathVariable Long id, Authentication authentication) { + String email = authentication.getName(); + Long userId = Long.valueOf(authentication.getDetails().toString()); + return walletService.respondToInvitation(id, email, userId, false); + } + + @GetMapping("/{walletId}/members") + public Mono> getWalletMembers(@PathVariable Long walletId, Authentication authentication) { + Long userId = Long.valueOf(authentication.getDetails().toString()); + return walletService.getWalletMembers(walletId, userId).collectList(); + } + + @DeleteMapping("/{walletId}/members/{memberIdToRemove}") + public Mono removeWalletMember(@PathVariable Long walletId, @PathVariable Long memberIdToRemove, Authentication authentication) { + Long ownerId = Long.valueOf(authentication.getDetails().toString()); + return walletService.removeWalletMember(walletId, ownerId, memberIdToRemove); + } + + @GetMapping("/user-contacts") + public Mono> getKnownContacts(Authentication authentication) { + Long userId = Long.valueOf(authentication.getDetails().toString()); + return walletService.getKnownContacts(userId).collectList(); + } + + @Data + static class CreateWalletRequest { + private String name; + private String nature; + private String icon; + private String color; + private String currency; + private java.math.BigDecimal initialBalance; + } + + @Data + static class InviteRequest { + private String email; + } +} diff --git a/kifi-api/src/main/java/com/kifi/api/dto/AuthRequest.java b/kifi-api/src/main/java/com/kifi/api/dto/AuthRequest.java new file mode 100644 index 0000000..ba2bb2e --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/dto/AuthRequest.java @@ -0,0 +1,9 @@ +package com.kifi.api.dto; + +import lombok.Data; + +@Data +public class AuthRequest { + private String email; + private String password; +} diff --git a/kifi-api/src/main/java/com/kifi/api/dto/AuthResponse.java b/kifi-api/src/main/java/com/kifi/api/dto/AuthResponse.java new file mode 100644 index 0000000..baede0a --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/dto/AuthResponse.java @@ -0,0 +1,16 @@ +package com.kifi.api.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AuthResponse { + private String token; + private Long userId; + private String email; +} diff --git a/kifi-api/src/main/java/com/kifi/api/dto/BudgetSummary.java b/kifi-api/src/main/java/com/kifi/api/dto/BudgetSummary.java new file mode 100644 index 0000000..83bcc55 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/dto/BudgetSummary.java @@ -0,0 +1,14 @@ +package com.kifi.api.dto; + +import com.kifi.api.entity.Budget; +import lombok.Builder; +import lombok.Data; + +import java.math.BigDecimal; + +@Data +@Builder +public class BudgetSummary { + private Budget budget; + private BigDecimal spentAmount; +} diff --git a/kifi-api/src/main/java/com/kifi/api/dto/OtpVerificationRequest.java b/kifi-api/src/main/java/com/kifi/api/dto/OtpVerificationRequest.java new file mode 100644 index 0000000..acee905 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/dto/OtpVerificationRequest.java @@ -0,0 +1,9 @@ +package com.kifi.api.dto; + +import lombok.Data; + +@Data +public class OtpVerificationRequest { + private String email; + private String otp; +} diff --git a/kifi-api/src/main/java/com/kifi/api/dto/ResetPasswordRequest.java b/kifi-api/src/main/java/com/kifi/api/dto/ResetPasswordRequest.java new file mode 100644 index 0000000..cac0599 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/dto/ResetPasswordRequest.java @@ -0,0 +1,10 @@ +package com.kifi.api.dto; + +import lombok.Data; + +@Data +public class ResetPasswordRequest { + private String email; + private String otp; + private String newPassword; +} diff --git a/kifi-api/src/main/java/com/kifi/api/dto/WalletMemberDTO.java b/kifi-api/src/main/java/com/kifi/api/dto/WalletMemberDTO.java new file mode 100644 index 0000000..1a0f899 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/dto/WalletMemberDTO.java @@ -0,0 +1,19 @@ +package com.kifi.api.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class WalletMemberDTO { + private Long userId; + private String email; + private String role; + private LocalDateTime joinedAt; +} diff --git a/kifi-api/src/main/java/com/kifi/api/entity/Budget.java b/kifi-api/src/main/java/com/kifi/api/entity/Budget.java new file mode 100644 index 0000000..669d339 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/entity/Budget.java @@ -0,0 +1,21 @@ +package com.kifi.api.entity; + +import lombok.Data; +import org.springframework.data.annotation.Id; +import org.springframework.data.relational.core.mapping.Table; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Data +@Table("budgets") +public class Budget { + @Id + private Long id; + private Long userId; + private Long categoryId; + private Long walletId; + private BigDecimal monthlyLimit; + private Boolean isShared; + private LocalDateTime createdAt; +} diff --git a/kifi-api/src/main/java/com/kifi/api/entity/Category.java b/kifi-api/src/main/java/com/kifi/api/entity/Category.java new file mode 100644 index 0000000..b7bec34 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/entity/Category.java @@ -0,0 +1,24 @@ +package com.kifi.api.entity; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import org.springframework.data.relational.core.mapping.Table; + +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Table("categories") +public class Category { + @Id + private Long id; + private Long userId; + private String name; + private String iconName; + private LocalDateTime createdAt; +} diff --git a/kifi-api/src/main/java/com/kifi/api/entity/RecurringTransaction.java b/kifi-api/src/main/java/com/kifi/api/entity/RecurringTransaction.java new file mode 100644 index 0000000..7cd1f7b --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/entity/RecurringTransaction.java @@ -0,0 +1,28 @@ +package com.kifi.api.entity; + +import lombok.Data; +import org.springframework.data.annotation.Id; +import org.springframework.data.relational.core.mapping.Table; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Data +@Table("recurring_transactions") +public class RecurringTransaction { + @Id + private Long id; + private Long userId; + private Long categoryId; + private Long fromWalletId; + private Long toWalletId; + private String type; + private BigDecimal amount; + private String frequency; // DAILY, WEEKLY, MONTHLY, YEARLY + private LocalDate nextExecutionDate; + private LocalDate endDate; + private String status; // ACTIVE, PAUSED + private String description; + private LocalDateTime createdAt; +} diff --git a/kifi-api/src/main/java/com/kifi/api/entity/Transaction.java b/kifi-api/src/main/java/com/kifi/api/entity/Transaction.java new file mode 100644 index 0000000..620a468 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/entity/Transaction.java @@ -0,0 +1,50 @@ +package com.kifi.api.entity; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import org.springframework.data.relational.core.mapping.Table; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Table("transactions") +public class Transaction { + @Id + private Long id; + private Long userId; + private Long categoryId; + private Long fromWalletId; + private Long toWalletId; + private String notes; + private String type; // INCOME or EXPENSE or INVESTMENT + private BigDecimal amount; + private LocalDate date; + private String description; + + // Payable fields + private LocalDate dueDate; + private String alertSchedule; + private java.time.LocalTime alertTime; + + // Investment fields + private String investmentStatus; // OPEN or CLOSED + private BigDecimal maturityAmount; + private BigDecimal profitLoss; + private LocalDate closingDate; + + private LocalDateTime createdAt; + + @org.springframework.data.annotation.Transient + private java.util.List items; + + @org.springframework.data.annotation.Transient + private java.util.List attachments; +} diff --git a/kifi-api/src/main/java/com/kifi/api/entity/TransactionAttachment.java b/kifi-api/src/main/java/com/kifi/api/entity/TransactionAttachment.java new file mode 100644 index 0000000..de376fd --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/entity/TransactionAttachment.java @@ -0,0 +1,25 @@ +package com.kifi.api.entity; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import org.springframework.data.relational.core.mapping.Table; + +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Table("transaction_attachments") +public class TransactionAttachment { + @Id + private Long id; + private Long transactionId; + private String fileName; + private String filePath; + private String contentType; + private LocalDateTime createdAt; +} diff --git a/kifi-api/src/main/java/com/kifi/api/entity/TransactionItem.java b/kifi-api/src/main/java/com/kifi/api/entity/TransactionItem.java new file mode 100644 index 0000000..3f51ffd --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/entity/TransactionItem.java @@ -0,0 +1,25 @@ +package com.kifi.api.entity; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import org.springframework.data.relational.core.mapping.Table; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Table("transaction_items") +public class TransactionItem { + @Id + private Long id; + private Long transactionId; + private String name; + private BigDecimal amount; + private LocalDateTime createdAt; +} diff --git a/kifi-api/src/main/java/com/kifi/api/entity/TransactionType.java b/kifi-api/src/main/java/com/kifi/api/entity/TransactionType.java new file mode 100644 index 0000000..61d0c5f --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/entity/TransactionType.java @@ -0,0 +1,6 @@ +package com.kifi.api.entity; + +public enum TransactionType { + INCOME, + EXPENSE +} diff --git a/kifi-api/src/main/java/com/kifi/api/entity/User.java b/kifi-api/src/main/java/com/kifi/api/entity/User.java new file mode 100644 index 0000000..01da5f3 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/entity/User.java @@ -0,0 +1,26 @@ +package com.kifi.api.entity; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import org.springframework.data.relational.core.mapping.Table; + +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Table("users") +public class User { + @Id + private Long id; + private String email; + private String password; + @Builder.Default + private Boolean enabled = false; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/kifi-api/src/main/java/com/kifi/api/entity/UserWallet.java b/kifi-api/src/main/java/com/kifi/api/entity/UserWallet.java new file mode 100644 index 0000000..fb665e6 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/entity/UserWallet.java @@ -0,0 +1,18 @@ +package com.kifi.api.entity; + +import lombok.Data; +import org.springframework.data.annotation.Id; +import org.springframework.data.relational.core.mapping.Table; + +import java.time.LocalDateTime; + +@Data +@Table("user_wallets") +public class UserWallet { + @Id + private Long id; // R2DBC sometimes needs a synthetic ID, but we have a composite PK in DB. We might need to just use a regular ID if this gives issues, but let's try mapping. + private Long userId; + private Long walletId; + private String role; + private LocalDateTime joinedAt; +} diff --git a/kifi-api/src/main/java/com/kifi/api/entity/Wallet.java b/kifi-api/src/main/java/com/kifi/api/entity/Wallet.java new file mode 100644 index 0000000..70f0a9e --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/entity/Wallet.java @@ -0,0 +1,22 @@ +package com.kifi.api.entity; + +import lombok.Data; +import org.springframework.data.annotation.Id; +import org.springframework.data.relational.core.mapping.Table; + +import java.time.LocalDateTime; + +@Data +@Table("wallets") +public class Wallet { + @Id + private Long id; + private String name; + private Long ownerId; + private String nature; + private java.math.BigDecimal balance; + private String currency; + private String icon; + private String color; + private LocalDateTime createdAt; +} diff --git a/kifi-api/src/main/java/com/kifi/api/entity/WalletInvitation.java b/kifi-api/src/main/java/com/kifi/api/entity/WalletInvitation.java new file mode 100644 index 0000000..6ee4a8a --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/entity/WalletInvitation.java @@ -0,0 +1,19 @@ +package com.kifi.api.entity; + +import lombok.Data; +import org.springframework.data.annotation.Id; +import org.springframework.data.relational.core.mapping.Table; + +import java.time.LocalDateTime; + +@Data +@Table("wallet_invitations") +public class WalletInvitation { + @Id + private Long id; + private Long walletId; + private Long inviterId; + private String inviteeEmail; + private String status; + private LocalDateTime createdAt; +} diff --git a/kifi-api/src/main/java/com/kifi/api/repository/BudgetRepository.java b/kifi-api/src/main/java/com/kifi/api/repository/BudgetRepository.java new file mode 100644 index 0000000..1f0edb1 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/repository/BudgetRepository.java @@ -0,0 +1,11 @@ +package com.kifi.api.repository; + +import com.kifi.api.entity.Budget; +import org.springframework.data.r2dbc.repository.R2dbcRepository; +import reactor.core.publisher.Flux; + +public interface BudgetRepository extends R2dbcRepository { + @org.springframework.data.r2dbc.repository.Query("SELECT b.* FROM budgets b WHERE b.user_id = :userId OR (b.is_shared = TRUE AND b.wallet_id IN (SELECT uw.wallet_id FROM user_wallets uw WHERE uw.user_id = :userId))") + Flux findByUserId(Long userId); + reactor.core.publisher.Mono deleteByWalletId(Long walletId); +} diff --git a/kifi-api/src/main/java/com/kifi/api/repository/CategoryRepository.java b/kifi-api/src/main/java/com/kifi/api/repository/CategoryRepository.java new file mode 100644 index 0000000..5446bf5 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/repository/CategoryRepository.java @@ -0,0 +1,11 @@ +package com.kifi.api.repository; + +import com.kifi.api.entity.Category; +import org.springframework.data.r2dbc.repository.R2dbcRepository; +import org.springframework.stereotype.Repository; +import reactor.core.publisher.Flux; + +@Repository +public interface CategoryRepository extends R2dbcRepository { + Flux findByUserId(Long userId); +} diff --git a/kifi-api/src/main/java/com/kifi/api/repository/RecurringTransactionRepository.java b/kifi-api/src/main/java/com/kifi/api/repository/RecurringTransactionRepository.java new file mode 100644 index 0000000..ea37c1c --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/repository/RecurringTransactionRepository.java @@ -0,0 +1,19 @@ +package com.kifi.api.repository; + +import com.kifi.api.entity.RecurringTransaction; +import org.springframework.data.r2dbc.repository.Query; +import org.springframework.data.r2dbc.repository.R2dbcRepository; +import org.springframework.stereotype.Repository; +import reactor.core.publisher.Flux; + +import java.time.LocalDate; + +@Repository +public interface RecurringTransactionRepository extends R2dbcRepository { + Flux findByUserId(Long userId); + + @Query("SELECT * FROM recurring_transactions WHERE status = 'ACTIVE' AND next_execution_date <= :date") + Flux findDueTransactions(LocalDate date); + + reactor.core.publisher.Mono countByFromWalletIdOrToWalletId(Long fromWalletId, Long toWalletId); +} diff --git a/kifi-api/src/main/java/com/kifi/api/repository/TransactionAttachmentRepository.java b/kifi-api/src/main/java/com/kifi/api/repository/TransactionAttachmentRepository.java new file mode 100644 index 0000000..6947344 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/repository/TransactionAttachmentRepository.java @@ -0,0 +1,11 @@ +package com.kifi.api.repository; + +import com.kifi.api.entity.TransactionAttachment; +import org.springframework.data.repository.reactive.ReactiveCrudRepository; +import org.springframework.stereotype.Repository; +import reactor.core.publisher.Flux; + +@Repository +public interface TransactionAttachmentRepository extends ReactiveCrudRepository { + Flux findByTransactionId(Long transactionId); +} diff --git a/kifi-api/src/main/java/com/kifi/api/repository/TransactionItemRepository.java b/kifi-api/src/main/java/com/kifi/api/repository/TransactionItemRepository.java new file mode 100644 index 0000000..dd58c6e --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/repository/TransactionItemRepository.java @@ -0,0 +1,11 @@ +package com.kifi.api.repository; + +import com.kifi.api.entity.TransactionItem; +import org.springframework.data.repository.reactive.ReactiveCrudRepository; +import org.springframework.stereotype.Repository; +import reactor.core.publisher.Flux; + +@Repository +public interface TransactionItemRepository extends ReactiveCrudRepository { + Flux findByTransactionId(Long transactionId); +} diff --git a/kifi-api/src/main/java/com/kifi/api/repository/TransactionRepository.java b/kifi-api/src/main/java/com/kifi/api/repository/TransactionRepository.java new file mode 100644 index 0000000..344333b --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/repository/TransactionRepository.java @@ -0,0 +1,14 @@ +package com.kifi.api.repository; + +import com.kifi.api.entity.Transaction; +import org.springframework.data.r2dbc.repository.R2dbcRepository; +import org.springframework.stereotype.Repository; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +@Repository +public interface TransactionRepository extends R2dbcRepository, TransactionRepositoryCustom { + @org.springframework.data.r2dbc.repository.Query("SELECT t.* FROM transactions t WHERE t.user_id = :userId OR t.from_wallet_id IN (SELECT uw.wallet_id FROM user_wallets uw WHERE uw.user_id = :userId) OR t.to_wallet_id IN (SELECT uw.wallet_id FROM user_wallets uw WHERE uw.user_id = :userId) ORDER BY t.date DESC") + Flux findVisibleTransactionsForUser(Long userId); + Mono countByFromWalletIdOrToWalletId(Long fromWalletId, Long toWalletId); +} diff --git a/kifi-api/src/main/java/com/kifi/api/repository/TransactionRepositoryCustom.java b/kifi-api/src/main/java/com/kifi/api/repository/TransactionRepositoryCustom.java new file mode 100644 index 0000000..9f944d1 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/repository/TransactionRepositoryCustom.java @@ -0,0 +1,20 @@ +package com.kifi.api.repository; + +import com.kifi.api.entity.Transaction; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import reactor.core.publisher.Mono; +import java.time.LocalDate; + +public interface TransactionRepositoryCustom { + Mono> findTransactionsWithFilters( + Long userId, + String type, + Long walletId, + Long categoryId, + LocalDate startDate, + LocalDate endDate, + String search, + Pageable pageable + ); +} diff --git a/kifi-api/src/main/java/com/kifi/api/repository/TransactionRepositoryImpl.java b/kifi-api/src/main/java/com/kifi/api/repository/TransactionRepositoryImpl.java new file mode 100644 index 0000000..3958416 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/repository/TransactionRepositoryImpl.java @@ -0,0 +1,108 @@ +package com.kifi.api.repository; + +import com.kifi.api.entity.Transaction; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.r2dbc.core.DatabaseClient; +import org.springframework.stereotype.Repository; +import reactor.core.publisher.Mono; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +@Repository +public class TransactionRepositoryImpl implements TransactionRepositoryCustom { + + private final DatabaseClient databaseClient; + + public TransactionRepositoryImpl(DatabaseClient databaseClient) { + this.databaseClient = databaseClient; + } + + @Override + public Mono> findTransactionsWithFilters( + Long userId, + String type, + Long walletId, + Long categoryId, + LocalDate startDate, + LocalDate endDate, + String search, + Pageable pageable) { + StringBuilder baseQuery = new StringBuilder("FROM transactions t WHERE (t.user_id = :userId OR t.from_wallet_id IN (SELECT uw.wallet_id FROM user_wallets uw WHERE uw.user_id = :userId) OR t.to_wallet_id IN (SELECT uw.wallet_id FROM user_wallets uw WHERE uw.user_id = :userId))"); + + if (type != null && !type.isEmpty()) { + baseQuery.append(" AND t.type = :type"); + } + if (walletId != null) { + baseQuery.append(" AND (t.from_wallet_id = :walletId OR t.to_wallet_id = :walletId)"); + } + if (categoryId != null) { + baseQuery.append(" AND t.category_id = :categoryId"); + } + if (startDate != null) { + baseQuery.append(" AND t.date >= :startDate"); + } + if (endDate != null) { + baseQuery.append(" AND t.date <= :endDate"); + } + if (search != null && !search.trim().isEmpty()) { + baseQuery.append(" AND t.description ILIKE :search"); + } + + String dataQueryStr = "SELECT t.* " + baseQuery.toString() + " ORDER BY t.date DESC LIMIT " + pageable.getPageSize() + " OFFSET " + pageable.getOffset(); + String countQueryStr = "SELECT COUNT(t.id) " + baseQuery.toString(); + + DatabaseClient.GenericExecuteSpec dataSpec = databaseClient.sql(dataQueryStr).bind("userId", userId); + DatabaseClient.GenericExecuteSpec countSpec = databaseClient.sql(countQueryStr).bind("userId", userId); + + if (type != null && !type.isEmpty()) { + dataSpec = dataSpec.bind("type", type); + countSpec = countSpec.bind("type", type); + } + if (walletId != null) { + dataSpec = dataSpec.bind("walletId", walletId); + countSpec = countSpec.bind("walletId", walletId); + } + if (categoryId != null) { + dataSpec = dataSpec.bind("categoryId", categoryId); + countSpec = countSpec.bind("categoryId", categoryId); + } + if (startDate != null) { + dataSpec = dataSpec.bind("startDate", startDate); + countSpec = countSpec.bind("startDate", startDate); + } + if (endDate != null) { + dataSpec = dataSpec.bind("endDate", endDate); + countSpec = countSpec.bind("endDate", endDate); + } + if (search != null && !search.trim().isEmpty()) { + dataSpec = dataSpec.bind("search", "%" + search.trim() + "%"); + countSpec = countSpec.bind("search", "%" + search.trim() + "%"); + } + + Mono> transactionsMono = dataSpec.map((row, metadata) -> { + Transaction t = new Transaction(); + t.setId(row.get("id", Long.class)); + t.setUserId(row.get("user_id", Long.class)); + t.setFromWalletId(row.get("from_wallet_id", Long.class)); + t.setToWalletId(row.get("to_wallet_id", Long.class)); + t.setCategoryId(row.get("category_id", Long.class)); + t.setType(row.get("type", String.class)); + t.setAmount(row.get("amount", BigDecimal.class)); + t.setDate(row.get("date", LocalDate.class)); + t.setDescription(row.get("description", String.class)); + t.setInvestmentStatus(row.get("investment_status", String.class)); + t.setProfitLoss(row.get("profit_loss", BigDecimal.class)); + return t; + }).all().collectList(); + + Mono countMono = countSpec.map((row, metadata) -> row.get(0, Long.class)).first().defaultIfEmpty(0L); + + return Mono.zip(transactionsMono, countMono) + .map(tuple -> new PageImpl<>(tuple.getT1(), pageable, tuple.getT2())); + } +} diff --git a/kifi-api/src/main/java/com/kifi/api/repository/UserRepository.java b/kifi-api/src/main/java/com/kifi/api/repository/UserRepository.java new file mode 100644 index 0000000..bc8c6ea --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/repository/UserRepository.java @@ -0,0 +1,13 @@ +package com.kifi.api.repository; + +import com.kifi.api.entity.User; +import org.springframework.data.r2dbc.repository.R2dbcRepository; +import org.springframework.data.r2dbc.repository.Query; +import org.springframework.stereotype.Repository; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +@Repository +public interface UserRepository extends R2dbcRepository { + Mono findByEmail(String email); +} diff --git a/kifi-api/src/main/java/com/kifi/api/repository/UserWalletRepository.java b/kifi-api/src/main/java/com/kifi/api/repository/UserWalletRepository.java new file mode 100644 index 0000000..47e3a10 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/repository/UserWalletRepository.java @@ -0,0 +1,12 @@ +package com.kifi.api.repository; + +import com.kifi.api.entity.UserWallet; +import org.springframework.data.repository.reactive.ReactiveCrudRepository; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public interface UserWalletRepository extends ReactiveCrudRepository { + Flux findByUserId(Long userId); + Flux findByWalletId(Long walletId); + Mono deleteByWalletId(Long walletId); +} diff --git a/kifi-api/src/main/java/com/kifi/api/repository/WalletInvitationRepository.java b/kifi-api/src/main/java/com/kifi/api/repository/WalletInvitationRepository.java new file mode 100644 index 0000000..c23559e --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/repository/WalletInvitationRepository.java @@ -0,0 +1,12 @@ +package com.kifi.api.repository; + +import com.kifi.api.entity.WalletInvitation; +import org.springframework.data.r2dbc.repository.R2dbcRepository; +import org.springframework.stereotype.Repository; +import reactor.core.publisher.Flux; + +@Repository +public interface WalletInvitationRepository extends R2dbcRepository { + Flux findByInviteeEmailAndStatus(String inviteeEmail, String status); + reactor.core.publisher.Mono deleteByWalletId(Long walletId); +} diff --git a/kifi-api/src/main/java/com/kifi/api/repository/WalletRepository.java b/kifi-api/src/main/java/com/kifi/api/repository/WalletRepository.java new file mode 100644 index 0000000..19dde62 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/repository/WalletRepository.java @@ -0,0 +1,9 @@ +package com.kifi.api.repository; + +import com.kifi.api.entity.Wallet; +import org.springframework.data.repository.reactive.ReactiveCrudRepository; +import reactor.core.publisher.Flux; + +public interface WalletRepository extends ReactiveCrudRepository { + Flux findByOwnerId(Long ownerId); +} diff --git a/kifi-api/src/main/java/com/kifi/api/scheduler/RecurringTransactionScheduler.java b/kifi-api/src/main/java/com/kifi/api/scheduler/RecurringTransactionScheduler.java new file mode 100644 index 0000000..a562ceb --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/scheduler/RecurringTransactionScheduler.java @@ -0,0 +1,69 @@ +package com.kifi.api.scheduler; + +import com.kifi.api.entity.RecurringTransaction; +import com.kifi.api.entity.Transaction; +import com.kifi.api.repository.RecurringTransactionRepository; +import com.kifi.api.service.TransactionService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Component +@RequiredArgsConstructor +@Slf4j +public class RecurringTransactionScheduler { + + private final RecurringTransactionRepository recurringRepository; + private final TransactionService transactionService; + + // Runs every day at midnight + @Scheduled(cron = "0 0 0 * * ?") + public void processRecurringTransactions() { + LocalDate today = LocalDate.now(); + log.info("Starting recurring transaction processing for {}", today); + + recurringRepository.findDueTransactions(today) + .flatMap(this::processSingle) + .subscribe( + success -> log.info("Processed recurring transaction: {}", success.getId()), + error -> log.error("Error processing recurring transactions", error), + () -> log.info("Finished processing recurring transactions for {}", today) + ); + } + + private Mono processSingle(RecurringTransaction rt) { + Transaction t = Transaction.builder() + .userId(rt.getUserId()) + .categoryId(rt.getCategoryId()) + .fromWalletId(rt.getFromWalletId()) + .toWalletId(rt.getToWalletId()) + .type(rt.getType()) + .amount(rt.getAmount()) + .date(rt.getNextExecutionDate()) // Process it as of the execution date + .description(rt.getDescription()) + .notes("Auto-generated from recurring transaction") + .createdAt(LocalDateTime.now()) + .build(); + + // Calculate next date + LocalDate nextDate = rt.getNextExecutionDate(); + switch (rt.getFrequency()) { + case "DAILY" -> nextDate = nextDate.plusDays(1); + case "WEEKLY" -> nextDate = nextDate.plusWeeks(1); + case "MONTHLY" -> nextDate = nextDate.plusMonths(1); + case "YEARLY" -> nextDate = nextDate.plusYears(1); + } + rt.setNextExecutionDate(nextDate); + if (rt.getEndDate() != null && nextDate.isAfter(rt.getEndDate())) { + rt.setStatus("COMPLETED"); + } + + return transactionService.addTransaction(rt.getUserId(), t) + .then(recurringRepository.save(rt)); + } +} diff --git a/kifi-api/src/main/java/com/kifi/api/security/AuthenticationManager.java b/kifi-api/src/main/java/com/kifi/api/security/AuthenticationManager.java new file mode 100644 index 0000000..7b1e5a1 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/security/AuthenticationManager.java @@ -0,0 +1,46 @@ +package com.kifi.api.security; + +import io.jsonwebtoken.Claims; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.ReactiveAuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; + +import java.util.Collections; + +@Component +@RequiredArgsConstructor +public class AuthenticationManager implements ReactiveAuthenticationManager { + + private final JwtUtil jwtUtil; + + @Override + public Mono authenticate(Authentication authentication) { + String authToken = authentication.getCredentials().toString(); + + try { + if (jwtUtil.validateToken(authToken)) { + Claims claims = jwtUtil.getAllClaimsFromToken(authToken); + String email = claims.getSubject(); + Long userId = claims.get("userId", Long.class); + + UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken( + email, + null, + Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")) + ); + + auth.setDetails(userId); + + return Mono.just(auth); + } else { + return Mono.empty(); + } + } catch (Exception e) { + return Mono.empty(); + } + } +} diff --git a/kifi-api/src/main/java/com/kifi/api/security/JwtUtil.java b/kifi-api/src/main/java/com/kifi/api/security/JwtUtil.java new file mode 100644 index 0000000..6f54bbb --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/security/JwtUtil.java @@ -0,0 +1,68 @@ +package com.kifi.api.security; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.util.Date; +import java.util.HashMap; + +@Component +public class JwtUtil { + + @Value("${jwt.secret}") + private String secret; + + @Value("${jwt.expiration}") + private String expirationTime; + + private SecretKey getSignKey() { + byte[] keyBytes = secret.getBytes(); + return Keys.hmacShaKeyFor(keyBytes); + } + + public Claims getAllClaimsFromToken(String token) { + return Jwts.parser() + .verifyWith(getSignKey()) + .build() + .parseSignedClaims(token) + .getPayload(); + } + + public String getEmailFromToken(String token) { + return getAllClaimsFromToken(token).getSubject(); + } + + public Date getExpirationDateFromToken(String token) { + return getAllClaimsFromToken(token).getExpiration(); + } + + private Boolean isTokenExpired(String token) { + final Date expiration = getExpirationDateFromToken(token); + return expiration.before(new Date()); + } + + public String generateToken(String email, Long userId) { + HashMap claims = new HashMap<>(); + claims.put("userId", userId); + + long expirationTimeLong = Long.parseLong(expirationTime); + final Date createdDate = new Date(); + final Date expirationDate = new Date(createdDate.getTime() + expirationTimeLong); + + return Jwts.builder() + .claims(claims) + .subject(email) + .issuedAt(createdDate) + .expiration(expirationDate) + .signWith(getSignKey()) + .compact(); + } + + public Boolean validateToken(String token) { + return !isTokenExpired(token); + } +} diff --git a/kifi-api/src/main/java/com/kifi/api/security/SecurityContextRepository.java b/kifi-api/src/main/java/com/kifi/api/security/SecurityContextRepository.java new file mode 100644 index 0000000..c222e75 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/security/SecurityContextRepository.java @@ -0,0 +1,38 @@ +package com.kifi.api.security; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextImpl; +import org.springframework.security.web.server.context.ServerSecurityContextRepository; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +@Component +@RequiredArgsConstructor +public class SecurityContextRepository implements ServerSecurityContextRepository { + + private final AuthenticationManager authenticationManager; + + @Override + public Mono save(ServerWebExchange exchange, SecurityContext context) { + throw new UnsupportedOperationException("Not supported yet."); + } + + @Override + public Mono load(ServerWebExchange exchange) { + String authHeader = exchange.getRequest().getHeaders().getFirst(HttpHeaders.AUTHORIZATION); + + if (authHeader != null && authHeader.startsWith("Bearer ")) { + String authToken = authHeader.substring(7); + Authentication auth = new UsernamePasswordAuthenticationToken(authToken, authToken); + return this.authenticationManager.authenticate(auth) + .map(SecurityContextImpl::new); + } + + return Mono.empty(); + } +} diff --git a/kifi-api/src/main/java/com/kifi/api/service/AuthService.java b/kifi-api/src/main/java/com/kifi/api/service/AuthService.java new file mode 100644 index 0000000..9098312 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/service/AuthService.java @@ -0,0 +1,157 @@ +package com.kifi.api.service; + +import com.kifi.api.dto.AuthRequest; +import com.kifi.api.dto.AuthResponse; +import com.kifi.api.entity.User; +import com.kifi.api.repository.UserRepository; +import com.kifi.api.security.JwtUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.ReactiveStringRedisTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Random; + +@Service +@RequiredArgsConstructor +@Slf4j +public class AuthService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final JwtUtil jwtUtil; + private final EmailService emailService; + private final ReactiveStringRedisTemplate redisTemplate; + private final CryptoService cryptoService; + + private String decryptField(String value) { + try { + return cryptoService.decrypt(value); + } catch (Exception e) { + // If decryption fails, treat as plaintext (backward compatibility) + log.warn("RSA decryption failed, treating as plaintext: {}", e.getMessage()); + return value; + } + } + + public Mono signup(AuthRequest request) { + String email = decryptField(request.getEmail()); + String password = decryptField(request.getPassword()); + + return userRepository.findByEmail(email) + .flatMap(existingUser -> { + if (existingUser.getEnabled()) { + return Mono.error(new RuntimeException("User already exists and is verified")); + } + return generateAndSendOtp(existingUser, "REGISTRATION"); + }) + .switchIfEmpty(Mono.defer(() -> { + User newUser = User.builder() + .email(email) + .password(passwordEncoder.encode(password)) + .enabled(false) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .build(); + return userRepository.save(newUser).flatMap(u -> generateAndSendOtp(u, "REGISTRATION")); + })) + .map(u -> "OTP sent to your email."); + } + + private Mono generateAndSendOtp(User user, String purpose) { + String otp = String.format("%06d", new Random().nextInt(999999)); + String redisKey = "OTP:" + purpose + ":" + user.getEmail(); + return redisTemplate.opsForValue() + .set(redisKey, otp, Duration.ofMinutes(5)) + .then(purpose.equals("REGISTRATION") + ? emailService.sendRegistrationOtp(user.getEmail(), otp) + : emailService.sendResetPasswordOtp(user.getEmail(), otp)) + .thenReturn(user); + } + + public Mono verifyOtp(String email, String otp) { + String redisKey = "OTP:REGISTRATION:" + email; + return redisTemplate.opsForValue().get(redisKey) + .switchIfEmpty( + // Fallback to old key format for backward compatibility + redisTemplate.opsForValue().get("OTP:" + email) + .switchIfEmpty(Mono.error(new RuntimeException("OTP not found or expired"))) + ) + .flatMap(savedOtp -> { + if (!savedOtp.equals(otp)) { + return Mono.error(new RuntimeException("Invalid OTP")); + } + return userRepository.findByEmail(email); + }) + .flatMap(user -> { + user.setEnabled(true); + user.setUpdatedAt(LocalDateTime.now()); + return userRepository.save(user); + }) + .flatMap(user -> redisTemplate.opsForValue().delete(redisKey).thenReturn(user)) + .map(user -> AuthResponse.builder() + .token(jwtUtil.generateToken(user.getEmail(), user.getId())) + .userId(user.getId()) + .email(user.getEmail()) + .build()); + } + + public Mono login(AuthRequest request) { + String email = decryptField(request.getEmail()); + String password = decryptField(request.getPassword()); + + return userRepository.findByEmail(email) + .switchIfEmpty(Mono.error(new RuntimeException("Invalid email or password"))) + .flatMap(user -> { + if (!user.getEnabled()) { + return Mono.error(new RuntimeException("Email not verified")); + } + if (passwordEncoder.matches(password, user.getPassword())) { + return Mono.just(AuthResponse.builder() + .token(jwtUtil.generateToken(user.getEmail(), user.getId())) + .userId(user.getId()) + .email(user.getEmail()) + .build()); + } else { + return Mono.error(new RuntimeException("Invalid email or password")); + } + }); + } + + public Mono forgotPassword(String email) { + return userRepository.findByEmail(email) + .switchIfEmpty(Mono.error(new RuntimeException("No account found with this email"))) + .flatMap(user -> { + if (!user.getEnabled()) { + return Mono.error(new RuntimeException("Account not verified yet")); + } + return generateAndSendOtp(user, "RESET"); + }) + .map(u -> "Password reset OTP sent to your email."); + } + + public Mono resetPassword(String email, String otp, String encryptedNewPassword) { + String redisKey = "OTP:RESET:" + email; + String newPassword = decryptField(encryptedNewPassword); + + return redisTemplate.opsForValue().get(redisKey) + .switchIfEmpty(Mono.error(new RuntimeException("OTP not found or expired"))) + .flatMap(savedOtp -> { + if (!savedOtp.equals(otp)) { + return Mono.error(new RuntimeException("Invalid OTP")); + } + return userRepository.findByEmail(email); + }) + .flatMap(user -> { + user.setPassword(passwordEncoder.encode(newPassword)); + user.setUpdatedAt(LocalDateTime.now()); + return userRepository.save(user); + }) + .flatMap(user -> redisTemplate.opsForValue().delete(redisKey).thenReturn(user)) + .map(u -> "Password reset successfully."); + } +} diff --git a/kifi-api/src/main/java/com/kifi/api/service/BudgetService.java b/kifi-api/src/main/java/com/kifi/api/service/BudgetService.java new file mode 100644 index 0000000..657a1eb --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/service/BudgetService.java @@ -0,0 +1,70 @@ +package com.kifi.api.service; + +import com.kifi.api.dto.BudgetSummary; +import com.kifi.api.entity.Budget; +import com.kifi.api.repository.BudgetRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.r2dbc.core.DatabaseClient; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Service +@RequiredArgsConstructor +public class BudgetService { + private final BudgetRepository budgetRepository; + private final DatabaseClient databaseClient; + + public Flux getBudgets(Long userId) { + return budgetRepository.findByUserId(userId); + } + + public Flux getBudgetSummaries(Long userId) { + return budgetRepository.findByUserId(userId) + .flatMap(budget -> { + String sql = "SELECT COALESCE(SUM(amount), 0) FROM transactions " + + "WHERE type = 'EXPENSE' AND " + + "date >= date_trunc('month', current_date) AND " + + "(category_id = :categoryId OR from_wallet_id = :walletId) AND " + + "(user_id = :userId OR from_wallet_id IN (SELECT uw.wallet_id FROM user_wallets uw WHERE uw.user_id = :userId))"; + + return databaseClient.sql(sql) + .bind("userId", userId) + .bind("categoryId", budget.getCategoryId() != null ? budget.getCategoryId() : -1) + .bind("walletId", budget.getWalletId() != null ? budget.getWalletId() : -1) + .map((row, rowMetadata) -> row.get(0, BigDecimal.class)) + .first() + .defaultIfEmpty(BigDecimal.ZERO) + .map(spent -> BudgetSummary.builder().budget(budget).spentAmount(spent).build()); + }); + } + + public Mono addOrUpdateBudget(Long userId, Budget budget) { + return budgetRepository.findByUserId(userId) + .filter(b -> (b.getCategoryId() != null && b.getCategoryId().equals(budget.getCategoryId())) || + (b.getWalletId() != null && b.getWalletId().equals(budget.getWalletId()))) + .next() + .flatMap(existing -> { + existing.setMonthlyLimit(budget.getMonthlyLimit()); + if (budget.getIsShared() != null) { + existing.setIsShared(budget.getIsShared()); + } + return budgetRepository.save(existing); + }) + .switchIfEmpty(Mono.defer(() -> { + budget.setUserId(userId); + budget.setCreatedAt(LocalDateTime.now()); + if (budget.getIsShared() == null) { + budget.setIsShared(false); + } + return budgetRepository.save(budget); + })); + } + + public Mono deleteBudget(Long id) { + return budgetRepository.deleteById(id); + } +} diff --git a/kifi-api/src/main/java/com/kifi/api/service/CategoryService.java b/kifi-api/src/main/java/com/kifi/api/service/CategoryService.java new file mode 100644 index 0000000..f32fd39 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/service/CategoryService.java @@ -0,0 +1,26 @@ +package com.kifi.api.service; + +import com.kifi.api.entity.Category; +import com.kifi.api.repository.CategoryRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.time.LocalDateTime; + +@Service +@RequiredArgsConstructor +public class CategoryService { + private final CategoryRepository categoryRepository; + + public Flux getCategories(Long userId) { + return categoryRepository.findByUserId(userId); + } + + public Mono addCategory(Long userId, Category category) { + category.setUserId(userId); + category.setCreatedAt(LocalDateTime.now()); + return categoryRepository.save(category); + } +} diff --git a/kifi-api/src/main/java/com/kifi/api/service/CryptoService.java b/kifi-api/src/main/java/com/kifi/api/service/CryptoService.java new file mode 100644 index 0000000..99a7359 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/service/CryptoService.java @@ -0,0 +1,48 @@ +package com.kifi.api.service; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import javax.crypto.Cipher; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.util.Base64; + +@Service +@Slf4j +public class CryptoService { + + private KeyPair keyPair; + + public CryptoService() { + try { + KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA"); + keyGen.initialize(2048); + this.keyPair = keyGen.generateKeyPair(); + log.info("RSA Key Pair generated successfully for CryptoService."); + } catch (Exception e) { + log.error("Failed to generate RSA Key Pair", e); + throw new RuntimeException("Failed to initialize CryptoService", e); + } + } + + public String getPublicKeyBase64() { + PublicKey publicKey = keyPair.getPublic(); + return Base64.getEncoder().encodeToString(publicKey.getEncoded()); + } + + public String decrypt(String encryptedBase64) { + try { + byte[] encryptedBytes = Base64.getDecoder().decode(encryptedBase64); + Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding"); + cipher.init(Cipher.DECRYPT_MODE, keyPair.getPrivate()); + byte[] decryptedBytes = cipher.doFinal(encryptedBytes); + return new String(decryptedBytes, "UTF-8"); + } catch (Exception e) { + log.error("Failed to decrypt RSA payload", e); + throw new RuntimeException("Failed to decrypt payload"); + } + } +} diff --git a/kifi-api/src/main/java/com/kifi/api/service/EmailService.java b/kifi-api/src/main/java/com/kifi/api/service/EmailService.java new file mode 100644 index 0000000..bd262ea --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/service/EmailService.java @@ -0,0 +1,172 @@ +package com.kifi.api.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +import jakarta.mail.internet.MimeMessage; + +@Service +@RequiredArgsConstructor +@Slf4j +public class EmailService { + + private final JavaMailSender javaMailSender; + + public String buildEmailTemplate(String title, String subtitle, String otp, String footerNote) { + return """ + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + +
+

Kifi

+

Smart Financial Management

+
+

%s

+

%s

+ + + + + +
+
+ %s +
+
+

+ ⏱ This code expires in 5 minutes +

+
+
+
+

%s

+

© 2026 Kifi by Sarascore. All rights reserved.

+
+
+ + + """.formatted(title, subtitle, otp, footerNote); + } + + public Mono sendRegistrationOtp(String to, String otp) { + String html = buildEmailTemplate( + "Welcome to Kifi! 🎉", + "Thank you for signing up. Please use the verification code below to complete your registration.", + otp, + "If you didn't create an account, you can safely ignore this email." + ); + return sendEmail(to, "Kifi – Verify Your Email", html); + } + + public String buildInvitationEmailTemplate(String title, String subtitle, String footerNote) { + return """ + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + +
+

Kifi

+

Smart Financial Management

+
+

%s

+

%s

+
+
+
+

%s

+

© 2026 Kifi by Sarascore. All rights reserved.

+
+
+ + + """.formatted(title, subtitle, footerNote); + } + + public Mono sendResetPasswordOtp(String to, String otp) { + String html = buildEmailTemplate( + "Reset Your Password 🔐", + "We received a request to reset your password. Use the code below to set a new password.", + otp, + "If you didn't request a password reset, please ignore this email. Your account is safe." + ); + return sendEmail(to, "Kifi – Password Reset Code", html); + } + + // Keep old method for backward compatibility + public Mono sendOtpEmail(String to, String otp) { + return sendRegistrationOtp(to, otp); + } + + public Mono sendEmail(String to, String subject, String htmlContent) { + return Mono.fromRunnable(() -> { + try { + MimeMessage message = javaMailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8"); + helper.setTo(to); + helper.setSubject(subject); + helper.setText(htmlContent, true); + javaMailSender.send(message); + log.info("Email sent to: {} with subject: {}", to, subject); + } catch (Exception e) { + log.error("Failed to send email", e); + throw new RuntimeException("Failed to send email"); + } + }).subscribeOn(Schedulers.boundedElastic()).then(); + } +} diff --git a/kifi-api/src/main/java/com/kifi/api/service/MinioServiceClient.java b/kifi-api/src/main/java/com/kifi/api/service/MinioServiceClient.java new file mode 100644 index 0000000..c2d23c8 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/service/MinioServiceClient.java @@ -0,0 +1,102 @@ +package com.kifi.api.service; + +import lombok.Data; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +@Service +public class MinioServiceClient { + + private final WebClient webClient; + + @Value("${minio.service.url:http://minio-service:1500}") + private String minioServiceUrl; + + @Value("${minio.service.bucket:kifi}") + private String bucketName; + + public MinioServiceClient(WebClient.Builder webClientBuilder) { + this.webClient = webClientBuilder.build(); + } + + @Data + public static class MinioUploadRequest { + private String bucketName; + private String directoryPath; + private String contentType; + private String fileName; + private String fileContentBase64; + private boolean overwrite; + } + + @Data + public static class MinioDeleteRequest { + private String bucketName; + private String type; + private String filePath; + } + + @Data + public static class MinioResponse { + private boolean success; + private String message; + private String filePath; + } + + public Mono uploadFile(String directoryPath, String contentType, String fileName, String base64Content) { + MinioUploadRequest request = new MinioUploadRequest(); + request.setBucketName(bucketName); + request.setDirectoryPath(directoryPath); + request.setContentType(contentType); + request.setFileName(fileName); + request.setFileContentBase64(base64Content); + request.setOverwrite(true); + + return webClient.post() + .uri(minioServiceUrl + "/api/v1/minio/upload") + .bodyValue(request) + .retrieve() + .bodyToMono(MinioResponse.class); + } + + public Mono deleteFile(String contentType, String filePath) { + MinioDeleteRequest request = new MinioDeleteRequest(); + request.setBucketName(bucketName); + request.setType(contentType); + request.setFilePath(filePath); + + return webClient.method(org.springframework.http.HttpMethod.DELETE) + .uri(minioServiceUrl + "/api/v1/minio/file") + .bodyValue(request) + .retrieve() + .bodyToMono(MinioResponse.class); + } + @Data + public static class MinioDownloadRequest { + private String bucketName; + private String type; + private String filePath; + } + + @Data + public static class MinioDownloadResponse { + private boolean success; + private String message; + private String base64Content; + } + + public Mono downloadFile(String contentType, String filePath) { + MinioDownloadRequest request = new MinioDownloadRequest(); + request.setBucketName(bucketName); + request.setType(contentType); + request.setFilePath(filePath); + + return webClient.post() + .uri(minioServiceUrl + "/api/v1/minio/download") + .bodyValue(request) + .retrieve() + .bodyToMono(MinioDownloadResponse.class); + } +} diff --git a/kifi-api/src/main/java/com/kifi/api/service/RecurringTransactionService.java b/kifi-api/src/main/java/com/kifi/api/service/RecurringTransactionService.java new file mode 100644 index 0000000..d9b19fa --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/service/RecurringTransactionService.java @@ -0,0 +1,48 @@ +package com.kifi.api.service; + +import com.kifi.api.entity.RecurringTransaction; +import com.kifi.api.repository.RecurringTransactionRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.time.LocalDateTime; + +@Service +@RequiredArgsConstructor +public class RecurringTransactionService { + private final RecurringTransactionRepository repository; + + public Flux getRecurringTransactions(Long userId) { + return repository.findByUserId(userId); + } + + public Mono addRecurringTransaction(Long userId, RecurringTransaction transaction) { + transaction.setUserId(userId); + if (transaction.getStatus() == null) transaction.setStatus("ACTIVE"); + transaction.setCreatedAt(LocalDateTime.now()); + return repository.save(transaction); + } + + public Mono updateRecurringTransaction(Long id, Long userId, RecurringTransaction updated) { + return repository.findById(id) + .filter(rt -> rt.getUserId().equals(userId)) + .flatMap(existing -> { + existing.setAmount(updated.getAmount()); + existing.setFrequency(updated.getFrequency()); + existing.setNextExecutionDate(updated.getNextExecutionDate()); + existing.setEndDate(updated.getEndDate()); + existing.setStatus(updated.getStatus()); + existing.setDescription(updated.getDescription()); + existing.setFromWalletId(updated.getFromWalletId()); + existing.setToWalletId(updated.getToWalletId()); + existing.setCategoryId(updated.getCategoryId()); + return repository.save(existing); + }); + } + + public Mono deleteRecurringTransaction(Long id) { + return repository.deleteById(id); + } +} diff --git a/kifi-api/src/main/java/com/kifi/api/service/ReportService.java b/kifi-api/src/main/java/com/kifi/api/service/ReportService.java new file mode 100644 index 0000000..e287ecf --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/service/ReportService.java @@ -0,0 +1,50 @@ +package com.kifi.api.service; + +import com.kifi.api.entity.Transaction; +import com.kifi.api.repository.TransactionRepository; +import lombok.RequiredArgsConstructor; +import org.apache.commons.csv.CSVFormat; +import org.apache.commons.csv.CSVPrinter; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; + +import java.io.ByteArrayOutputStream; +import java.io.PrintWriter; +import java.nio.charset.StandardCharsets; +import java.time.format.DateTimeFormatter; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class ReportService { + private final TransactionRepository transactionRepository; + + public Mono exportTransactionsToCsv(Long userId) { + return transactionRepository.findVisibleTransactionsForUser(userId) + .collectList() + .map(this::generateCsvBytes); + } + + private byte[] generateCsvBytes(List transactions) { + try (ByteArrayOutputStream out = new ByteArrayOutputStream(); + PrintWriter pw = new PrintWriter(out, true, StandardCharsets.UTF_8); + CSVPrinter csvPrinter = new CSVPrinter(pw, CSVFormat.DEFAULT.builder().setHeader("ID", "Date", "Type", "Amount", "Description").build())) { + + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + for (Transaction tx : transactions) { + csvPrinter.printRecord( + tx.getId(), + tx.getDate() != null ? tx.getDate().format(formatter) : "", + tx.getType(), + tx.getAmount(), + tx.getDescription() != null ? tx.getDescription() : "" + ); + } + csvPrinter.flush(); + return out.toByteArray(); + } catch (Exception e) { + throw new RuntimeException("Error generating CSV", e); + } + } +} diff --git a/kifi-api/src/main/java/com/kifi/api/service/TransactionService.java b/kifi-api/src/main/java/com/kifi/api/service/TransactionService.java new file mode 100644 index 0000000..2139b9c --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/service/TransactionService.java @@ -0,0 +1,227 @@ +package com.kifi.api.service; + +import com.kifi.api.entity.Transaction; +import com.kifi.api.entity.TransactionAttachment; +import com.kifi.api.entity.TransactionItem; +import com.kifi.api.repository.TransactionAttachmentRepository; +import com.kifi.api.repository.TransactionItemRepository; +import com.kifi.api.repository.TransactionRepository; +import com.kifi.api.repository.WalletRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferUtils; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.http.codec.multipart.FilePart; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class TransactionService { + private final TransactionRepository transactionRepository; + private final TransactionItemRepository transactionItemRepository; + private final TransactionAttachmentRepository transactionAttachmentRepository; + private final WalletRepository walletRepository; + private final MinioServiceClient minioServiceClient; + + public Flux getTransactions(Long userId) { + return transactionRepository.findVisibleTransactionsForUser(userId) + .flatMap(this::populateItemsAndAttachments); + } + + public Mono> searchTransactions(Long userId, String type, Long walletId, Long categoryId, LocalDate startDate, LocalDate endDate, String search, Pageable pageable) { + return transactionRepository.findTransactionsWithFilters(userId, type, walletId, categoryId, startDate, endDate, search, pageable) + .flatMap(page -> Flux.fromIterable(page.getContent()) + .flatMap(this::populateItemsAndAttachments) + .collectList() + .map(populatedList -> new org.springframework.data.domain.PageImpl<>(populatedList, pageable, page.getTotalElements()))); + } + + private Mono populateItemsAndAttachments(Transaction transaction) { + Mono> itemsMono = transactionItemRepository.findByTransactionId(transaction.getId()).collectList(); + Mono> attachmentsMono = transactionAttachmentRepository.findByTransactionId(transaction.getId()).collectList(); + + return Mono.zip(itemsMono, attachmentsMono).map(tuple -> { + transaction.setItems(tuple.getT1()); + transaction.setAttachments(tuple.getT2()); + return transaction; + }); + } + + @Transactional + public Mono addTransaction(Long userId, Transaction transaction) { + transaction.setUserId(userId); + transaction.setCreatedAt(LocalDateTime.now()); + if ("INVESTMENT".equals(transaction.getType())) { + transaction.setInvestmentStatus("OPEN"); + } + + Mono updateBalances = updateWalletBalances(transaction.getFromWalletId(), transaction.getToWalletId(), transaction.getAmount()); + + return updateBalances.then(transactionRepository.save(transaction)).flatMap(savedTx -> { + Mono itemsMono = Mono.empty(); + if (transaction.getItems() != null && !transaction.getItems().isEmpty()) { + for (TransactionItem item : transaction.getItems()) { + item.setTransactionId(savedTx.getId()); + item.setCreatedAt(LocalDateTime.now()); + } + itemsMono = transactionItemRepository.saveAll(transaction.getItems()).then(); + } + return itemsMono.thenReturn(savedTx); + }).flatMap(this::populateItemsAndAttachments); + } + + private Mono updateWalletBalances(Long fromWalletId, Long toWalletId, BigDecimal amount) { + Mono deductFrom = fromWalletId != null ? walletRepository.findById(fromWalletId) + .flatMap(w -> { + w.setBalance(w.getBalance().subtract(amount)); + return walletRepository.save(w); + }).then() : Mono.empty(); + + Mono addTo = toWalletId != null ? walletRepository.findById(toWalletId) + .flatMap(w -> { + w.setBalance(w.getBalance().add(amount)); + return walletRepository.save(w); + }).then() : Mono.empty(); + + return deductFrom.then(addTo); + } + + public Mono addAttachment(Long transactionId, String fileName, String contentType, String base64Content) { + String uniqueFileName = UUID.randomUUID().toString() + "_" + fileName; + String directoryPath = "transactions/" + transactionId; + + return minioServiceClient.uploadFile(directoryPath, contentType, uniqueFileName, base64Content) + .flatMap(minioResponse -> { + if (minioResponse.isSuccess()) { + TransactionAttachment attachment = TransactionAttachment.builder() + .transactionId(transactionId) + .fileName(fileName) + .filePath(minioResponse.getFilePath()) + .contentType(contentType) + .createdAt(LocalDateTime.now()) + .build(); + return transactionAttachmentRepository.save(attachment); + } else { + return Mono.error(new RuntimeException("Failed to upload to MinIO")); + } + }); + } + + public Mono deleteAttachment(Long attachmentId) { + return transactionAttachmentRepository.findById(attachmentId) + .flatMap(attachment -> + minioServiceClient.deleteFile(attachment.getContentType(), attachment.getFilePath()) + .then(transactionAttachmentRepository.delete(attachment)) + ); + } + + public Mono downloadAttachment(Long attachmentId) { + return transactionAttachmentRepository.findById(attachmentId) + .switchIfEmpty(Mono.error(new RuntimeException("Attachment not found"))) + .flatMap(attachment -> + minioServiceClient.downloadFile(attachment.getContentType(), attachment.getFilePath()) + ); + } + + @Transactional + public Mono updateTransaction(Long id, Long userId, Transaction updatedTransaction) { + return transactionRepository.findById(id) + .filter(t -> t.getUserId().equals(userId)) + .flatMap(t -> { + // Revert old balances + Mono revertBalances = updateWalletBalances(t.getToWalletId(), t.getFromWalletId(), t.getAmount()); + + // Apply new balances + Mono applyNewBalances = updateWalletBalances(updatedTransaction.getFromWalletId(), updatedTransaction.getToWalletId(), updatedTransaction.getAmount()); + + t.setCategoryId(updatedTransaction.getCategoryId()); + t.setFromWalletId(updatedTransaction.getFromWalletId()); + t.setToWalletId(updatedTransaction.getToWalletId()); + t.setNotes(updatedTransaction.getNotes()); + t.setType(updatedTransaction.getType()); + t.setAmount(updatedTransaction.getAmount()); + t.setDate(updatedTransaction.getDate()); + t.setDescription(updatedTransaction.getDescription()); + + return revertBalances.then(applyNewBalances).then(transactionRepository.save(t)); + }).flatMap(this::populateItemsAndAttachments); + } + + @Transactional + public Mono closeInvestment(Long id, Long userId, BigDecimal maturityAmount, LocalDate closingDate, Long destinationWalletId) { + return transactionRepository.findById(id) + .filter(t -> t.getUserId().equals(userId) && "OPEN".equals(t.getInvestmentStatus())) + .flatMap(t -> { + t.setInvestmentStatus("CLOSED"); + t.setClosingDate(closingDate); + t.setMaturityAmount(maturityAmount); + BigDecimal profitLoss = maturityAmount.subtract(t.getAmount()); + t.setProfitLoss(profitLoss); + + Mono profitLossProcess = Mono.empty(); + if (profitLoss.compareTo(BigDecimal.ZERO) > 0) { + Transaction profit = new Transaction(); + profit.setUserId(userId); + profit.setType("INCOME"); + profit.setAmount(profitLoss); + profit.setDate(closingDate); + profit.setToWalletId(t.getToWalletId()); + profit.setDescription("Profit from " + (t.getDescription() != null ? t.getDescription() : "Investment")); + profitLossProcess = transactionRepository.save(profit) + .flatMap(savedProfit -> updateWalletBalances(null, savedProfit.getToWalletId(), profitLoss)); + } else if (profitLoss.compareTo(BigDecimal.ZERO) < 0) { + Transaction loss = new Transaction(); + loss.setUserId(userId); + loss.setType("EXPENSE"); + loss.setAmount(profitLoss.abs()); + loss.setDate(closingDate); + loss.setFromWalletId(t.getToWalletId()); + loss.setDescription("Loss from " + (t.getDescription() != null ? t.getDescription() : "Investment")); + profitLossProcess = transactionRepository.save(loss) + .flatMap(savedLoss -> updateWalletBalances(savedLoss.getFromWalletId(), null, profitLoss.abs())); + } + + // Create transfer transaction from the investment wallet to the savings wallet + if (destinationWalletId != null) { + Transaction transfer = new Transaction(); + transfer.setUserId(userId); + transfer.setType("TRANSFER"); + transfer.setAmount(maturityAmount); + transfer.setDate(closingDate); + transfer.setFromWalletId(t.getToWalletId()); // The money was in the investment wallet + transfer.setToWalletId(destinationWalletId); + transfer.setDescription("Closure of " + (t.getDescription() != null ? t.getDescription() : "Investment")); + + return transactionRepository.save(t) + .then(profitLossProcess) + .then(transactionRepository.save(transfer)) + .flatMap(savedTransfer -> updateWalletBalances(savedTransfer.getFromWalletId(), savedTransfer.getToWalletId(), maturityAmount)) + .thenReturn(t); + } else { + return transactionRepository.save(t).then(profitLossProcess).thenReturn(t); + } + }).flatMap(this::populateItemsAndAttachments); + } + + @Transactional + public Mono deleteTransaction(Long id) { + return transactionRepository.findById(id).flatMap(t -> { + Mono revertBalances = updateWalletBalances(t.getToWalletId(), t.getFromWalletId(), t.getAmount()); + return revertBalances.then( + transactionAttachmentRepository.findByTransactionId(id) + .flatMap(attachment -> minioServiceClient.deleteFile(attachment.getContentType(), attachment.getFilePath())) + .then(transactionRepository.deleteById(id)) + ); + }); + } +} diff --git a/kifi-api/src/main/java/com/kifi/api/service/WalletService.java b/kifi-api/src/main/java/com/kifi/api/service/WalletService.java new file mode 100644 index 0000000..5023070 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/service/WalletService.java @@ -0,0 +1,192 @@ +package com.kifi.api.service; + +import com.kifi.api.entity.UserWallet; +import com.kifi.api.entity.Wallet; +import com.kifi.api.entity.WalletInvitation; +import com.kifi.api.repository.BudgetRepository; +import com.kifi.api.repository.RecurringTransactionRepository; +import com.kifi.api.repository.TransactionRepository; +import com.kifi.api.repository.UserWalletRepository; +import com.kifi.api.repository.WalletInvitationRepository; +import com.kifi.api.repository.WalletRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.time.LocalDateTime; + +@Service +@RequiredArgsConstructor +public class WalletService { + private final WalletRepository walletRepository; + private final UserWalletRepository userWalletRepository; + private final WalletInvitationRepository walletInvitationRepository; + private final TransactionRepository transactionRepository; + private final BudgetRepository budgetRepository; + private final RecurringTransactionRepository recurringTransactionRepository; + private final EmailService emailService; + private final com.kifi.api.repository.UserRepository userRepository; + + public Mono createWallet(Long ownerId, String name, String nature, String icon, String color, String currency, java.math.BigDecimal initialBalance) { + Wallet wallet = new Wallet(); + wallet.setOwnerId(ownerId); + wallet.setName(name); + wallet.setNature(nature != null ? nature : "CASH"); + wallet.setBalance(initialBalance != null ? initialBalance : java.math.BigDecimal.ZERO); + wallet.setCurrency(currency != null ? currency : "INR"); + wallet.setIcon(icon); + wallet.setColor(color); + wallet.setCreatedAt(LocalDateTime.now()); + + return walletRepository.save(wallet) + .flatMap(savedWallet -> { + UserWallet uw = new UserWallet(); + uw.setUserId(ownerId); + uw.setWalletId(savedWallet.getId()); + uw.setRole("OWNER"); + uw.setJoinedAt(LocalDateTime.now()); + return userWalletRepository.save(uw).thenReturn(savedWallet); + }); + } + + public Flux getWalletsForUser(Long userId) { + return userWalletRepository.findByUserId(userId) + .flatMap(uw -> walletRepository.findById(uw.getWalletId())); + } + + public Mono inviteUserToWallet(Long walletId, Long userIdToInvite) { + UserWallet uw = new UserWallet(); + uw.setUserId(userIdToInvite); + uw.setWalletId(walletId); + uw.setRole("MEMBER"); + uw.setJoinedAt(LocalDateTime.now()); + return userWalletRepository.save(uw); + } + + public Mono inviteUserByEmail(Long inviterId, Long walletId, String inviteeEmail) { + return walletRepository.findById(walletId) + .flatMap(wallet -> { + WalletInvitation invitation = new WalletInvitation(); + invitation.setWalletId(walletId); + invitation.setInviterId(inviterId); + invitation.setInviteeEmail(inviteeEmail); + invitation.setStatus("PENDING"); + invitation.setCreatedAt(LocalDateTime.now()); + return walletInvitationRepository.save(invitation) + .flatMap(savedInv -> { + String emailHtml = emailService.buildInvitationEmailTemplate( + "Wallet Invitation 🤝", + "You have been invited to join the wallet: " + wallet.getName(), + "Please log in to the Kifi app with this email to accept or decline the invitation." + ); + return emailService.sendEmail(inviteeEmail, "Kifi – Wallet Invitation", emailHtml) + .thenReturn(savedInv) + .onErrorResume(e -> Mono.just(savedInv)); // Return saved invitation even if email fails + }); + }); + } + + public Flux getPendingInvitations(String email) { + return walletInvitationRepository.findByInviteeEmailAndStatus(email, "PENDING"); + } + + public Mono respondToInvitation(Long invitationId, String email, Long userId, boolean accept) { + return walletInvitationRepository.findById(invitationId) + .filter(inv -> inv.getInviteeEmail().equalsIgnoreCase(email) && "PENDING".equals(inv.getStatus())) + .flatMap(inv -> { + inv.setStatus(accept ? "ACCEPTED" : "REJECTED"); + return walletInvitationRepository.save(inv) + .flatMap(savedInv -> { + if (accept) { + UserWallet uw = new UserWallet(); + uw.setUserId(userId); + uw.setWalletId(savedInv.getWalletId()); + uw.setRole("MEMBER"); + uw.setJoinedAt(LocalDateTime.now()); + return userWalletRepository.save(uw).then(); + } + return Mono.empty(); + }); + }); + } + + public Mono editWallet(Long walletId, Long ownerId, String name, String nature, String icon, String color, String currency) { + return walletRepository.findById(walletId) + .filter(w -> w.getOwnerId().equals(ownerId)) + .switchIfEmpty(Mono.error(new RuntimeException("Wallet not found or you are not the owner"))) + .flatMap(w -> { + if (name != null) w.setName(name); + if (nature != null) w.setNature(nature); + if (icon != null) w.setIcon(icon); + if (color != null) w.setColor(color); + if (currency != null) w.setCurrency(currency); + return walletRepository.save(w); + }); + } + + public Mono deleteWallet(Long walletId, Long ownerId) { + return walletRepository.findById(walletId) + .filter(w -> w.getOwnerId().equals(ownerId)) + .switchIfEmpty(Mono.error(new RuntimeException("Wallet not found or you are not the owner"))) + .flatMap(w -> transactionRepository.countByFromWalletIdOrToWalletId(walletId, walletId) + .flatMap(txCount -> recurringTransactionRepository.countByFromWalletIdOrToWalletId(walletId, walletId) + .flatMap(rtCount -> { + if (txCount > 0 || rtCount > 0) { + return Mono.error(new RuntimeException("Cannot delete wallet with transactions")); + } + return budgetRepository.deleteByWalletId(walletId) + .then(walletInvitationRepository.deleteByWalletId(walletId)) + .then(userWalletRepository.deleteByWalletId(walletId)) + .then(walletRepository.deleteById(walletId)); + }) + ) + ); + } + + public Flux getWalletMembers(Long walletId, Long requestingUserId) { + // Ensure requesting user has access to this wallet + return userWalletRepository.findByUserId(requestingUserId) + .filter(uw -> uw.getWalletId().equals(walletId)) + .switchIfEmpty(Mono.error(new RuntimeException("You do not have access to this wallet"))) + .next() + .flatMapMany(uw -> userWalletRepository.findByWalletId(walletId) + .flatMap(memberUw -> userRepository.findById(memberUw.getUserId()) + .map(user -> com.kifi.api.dto.WalletMemberDTO.builder() + .userId(user.getId()) + .email(user.getEmail()) + .role(memberUw.getRole()) + .joinedAt(memberUw.getJoinedAt()) + .build() + ) + ) + ); + } + + public Mono removeWalletMember(Long walletId, Long ownerId, Long memberIdToRemove) { + return walletRepository.findById(walletId) + .filter(w -> w.getOwnerId().equals(ownerId)) + .switchIfEmpty(Mono.error(new RuntimeException("Wallet not found or you are not the owner"))) + .flatMap(w -> { + if (ownerId.equals(memberIdToRemove)) { + return Mono.error(new RuntimeException("Cannot remove the owner of the wallet")); + } + return userWalletRepository.findByWalletId(walletId) + .filter(uw -> uw.getUserId().equals(memberIdToRemove)) + .next() + .flatMap(uw -> userWalletRepository.delete(uw)); + }); + } + + @Autowired + private org.springframework.r2dbc.core.DatabaseClient databaseClient; + + public Flux getKnownContacts(Long currentUserId) { + String sql = "SELECT DISTINCT u.email FROM users u JOIN user_wallets uw ON u.id = uw.user_id WHERE uw.wallet_id IN (SELECT wallet_id FROM user_wallets WHERE user_id = :userId) AND u.id != :userId"; + return databaseClient.sql(sql) + .bind("userId", currentUserId) + .map((row, rowMetadata) -> row.get("email", String.class)) + .all(); + } +} diff --git a/kifi-api/src/main/resources/application.yml b/kifi-api/src/main/resources/application.yml new file mode 100644 index 0000000..3138e5e --- /dev/null +++ b/kifi-api/src/main/resources/application.yml @@ -0,0 +1,48 @@ +spring: + application: + name: kifi-api + + r2dbc: + url: r2dbc:postgresql://103.125.129.116:5333/kifi + username: postgres + password: M@triXPostgr3s@6202 + pool: + initial-size: 5 + max-size: 20 + + sql: + init: + mode: never + schema-locations: classpath:schema.sql + + data: + redis: + host: 103.125.129.116 + port: 7901 + password: M@triXR3d1s@6202 + + mail: + host: smtp.gmail.com + port: 587 + username: technobeesolutions@gmail.com + password: lrideibfakickldg + properties: + mail: + smtp: + auth: true + starttls: + enable: true + + minio: + service: + url: http://103.125.129.116:1500 + bucket: kifi + +jwt: + secret: 404E635266556A586E3272357538782F413F4428472B4B6250645367566B5970 + expiration: 604800000 # 7 days + +logging: + level: + org.springframework.data.r2dbc: DEBUG + org.springframework.r2dbc: DEBUG diff --git a/kifi-api/src/main/resources/schema.sql b/kifi-api/src/main/resources/schema.sql new file mode 100644 index 0000000..fb407b8 --- /dev/null +++ b/kifi-api/src/main/resources/schema.sql @@ -0,0 +1,134 @@ + +CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + email VARCHAR(255) UNIQUE NOT NULL, + password VARCHAR(255), + enabled BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS merchants ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id), + name VARCHAR(255) NOT NULL, + icon_name VARCHAR(255), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS categories ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id), + name VARCHAR(255) NOT NULL, + icon_name VARCHAR(255), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS transactions ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id), + category_id INTEGER REFERENCES categories(id), + type VARCHAR(50) NOT NULL, -- 'INCOME' or 'EXPENSE' or 'INVESTMENT' + amount DECIMAL(10, 2) NOT NULL, + date DATE NOT NULL, + description TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS budgets ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id), + category_id INTEGER REFERENCES categories(id), + monthly_limit DECIMAL(10, 2) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, category_id) +); + +CREATE TABLE IF NOT EXISTS recurring_transactions ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id), + category_id INTEGER REFERENCES categories(id), + type VARCHAR(50) NOT NULL, + amount DECIMAL(10, 2) NOT NULL, + frequency VARCHAR(50) NOT NULL, + next_execution_date DATE NOT NULL, + description TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS wallets ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + owner_id INTEGER REFERENCES users(id), + nature VARCHAR(50) DEFAULT 'CASH', -- 'CASH', 'DEPOSIT', 'EXPENSE', 'INCOME', 'SAVINGS', 'LOAN', etc. + balance DECIMAL(15, 2) DEFAULT 0.00, + currency VARCHAR(10) DEFAULT 'INR', + icon VARCHAR(255), + color VARCHAR(50), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS user_wallets ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id), + wallet_id INTEGER REFERENCES wallets(id), + role VARCHAR(50) DEFAULT 'MEMBER', + joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE (user_id, wallet_id) +); + +ALTER TABLE wallets ADD COLUMN IF NOT EXISTS nature VARCHAR(50) DEFAULT 'CASH'; +ALTER TABLE wallets ADD COLUMN IF NOT EXISTS balance DECIMAL(15, 2) DEFAULT 0.00; +ALTER TABLE wallets ADD COLUMN IF NOT EXISTS currency VARCHAR(10) DEFAULT 'INR'; +ALTER TABLE wallets ADD COLUMN IF NOT EXISTS icon VARCHAR(255); +ALTER TABLE wallets ADD COLUMN IF NOT EXISTS color VARCHAR(50); + +ALTER TABLE transactions ADD COLUMN IF NOT EXISTS wallet_id INTEGER REFERENCES wallets(id); +ALTER TABLE transactions ADD COLUMN IF NOT EXISTS from_wallet_id INTEGER REFERENCES wallets(id); +ALTER TABLE transactions ADD COLUMN IF NOT EXISTS to_wallet_id INTEGER REFERENCES wallets(id); +ALTER TABLE transactions ADD COLUMN IF NOT EXISTS notes TEXT; +ALTER TABLE transactions ADD COLUMN IF NOT EXISTS due_date DATE; +ALTER TABLE transactions ADD COLUMN IF NOT EXISTS alert_schedule VARCHAR(50); +ALTER TABLE transactions ADD COLUMN IF NOT EXISTS alert_time TIME; + +ALTER TABLE transactions ADD COLUMN IF NOT EXISTS investment_status VARCHAR(20) DEFAULT 'OPEN'; +ALTER TABLE transactions ADD COLUMN IF NOT EXISTS maturity_amount DECIMAL(10, 2); +ALTER TABLE transactions ADD COLUMN IF NOT EXISTS profit_loss DECIMAL(10, 2); +ALTER TABLE transactions ADD COLUMN IF NOT EXISTS closing_date DATE; + +CREATE TABLE IF NOT EXISTS transaction_items ( + id SERIAL PRIMARY KEY, + transaction_id INTEGER REFERENCES transactions(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + amount DECIMAL(10, 2) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS transaction_attachments ( + id SERIAL PRIMARY KEY, + transaction_id INTEGER REFERENCES transactions(id) ON DELETE CASCADE, + file_name VARCHAR(255) NOT NULL, + file_path VARCHAR(1024) NOT NULL, + content_type VARCHAR(100), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +ALTER TABLE budgets ADD COLUMN IF NOT EXISTS wallet_id INTEGER REFERENCES wallets(id); +ALTER TABLE budgets ADD COLUMN IF NOT EXISTS is_shared BOOLEAN DEFAULT FALSE; +-- Ensure budget can be either category-specific or wallet-specific +ALTER TABLE budgets ALTER COLUMN category_id DROP NOT NULL; + +ALTER TABLE recurring_transactions ADD COLUMN IF NOT EXISTS from_wallet_id INTEGER REFERENCES wallets(id); +ALTER TABLE recurring_transactions ADD COLUMN IF NOT EXISTS to_wallet_id INTEGER REFERENCES wallets(id); +ALTER TABLE recurring_transactions ADD COLUMN IF NOT EXISTS status VARCHAR(20) DEFAULT 'ACTIVE'; +ALTER TABLE recurring_transactions ADD COLUMN IF NOT EXISTS end_date DATE; +ALTER TABLE recurring_transactions ALTER COLUMN category_id DROP NOT NULL; + +CREATE TABLE IF NOT EXISTS wallet_invitations ( + id SERIAL PRIMARY KEY, + wallet_id INTEGER REFERENCES wallets(id), + inviter_id INTEGER REFERENCES users(id), + invitee_email VARCHAR(255) NOT NULL, + status VARCHAR(20) DEFAULT 'PENDING', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); diff --git a/kifi-api/src/test/java/com/kifi/api/KifiApiApplicationTests.java b/kifi-api/src/test/java/com/kifi/api/KifiApiApplicationTests.java new file mode 100644 index 0000000..53c14ea --- /dev/null +++ b/kifi-api/src/test/java/com/kifi/api/KifiApiApplicationTests.java @@ -0,0 +1,13 @@ +package com.kifi.api; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class KifiApiApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/kifi-app/.gitignore b/kifi-app/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/kifi-app/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/kifi-app/.metadata b/kifi-app/.metadata new file mode 100644 index 0000000..41a1979 --- /dev/null +++ b/kifi-app/.metadata @@ -0,0 +1,45 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "8b872868494e429d94fa06dca855c306438b22c0" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 8b872868494e429d94fa06dca855c306438b22c0 + base_revision: 8b872868494e429d94fa06dca855c306438b22c0 + - platform: android + create_revision: 8b872868494e429d94fa06dca855c306438b22c0 + base_revision: 8b872868494e429d94fa06dca855c306438b22c0 + - platform: ios + create_revision: 8b872868494e429d94fa06dca855c306438b22c0 + base_revision: 8b872868494e429d94fa06dca855c306438b22c0 + - platform: linux + create_revision: 8b872868494e429d94fa06dca855c306438b22c0 + base_revision: 8b872868494e429d94fa06dca855c306438b22c0 + - platform: macos + create_revision: 8b872868494e429d94fa06dca855c306438b22c0 + base_revision: 8b872868494e429d94fa06dca855c306438b22c0 + - platform: web + create_revision: 8b872868494e429d94fa06dca855c306438b22c0 + base_revision: 8b872868494e429d94fa06dca855c306438b22c0 + - platform: windows + create_revision: 8b872868494e429d94fa06dca855c306438b22c0 + base_revision: 8b872868494e429d94fa06dca855c306438b22c0 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/kifi-app/README.md b/kifi-app/README.md new file mode 100644 index 0000000..770d8b3 --- /dev/null +++ b/kifi-app/README.md @@ -0,0 +1,16 @@ +# kifi_app + +A new Flutter project. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/kifi-app/analysis_options.yaml b/kifi-app/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/kifi-app/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/kifi-app/android/.gitignore b/kifi-app/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/kifi-app/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/kifi-app/android/app/build.gradle.kts b/kifi-app/android/app/build.gradle.kts new file mode 100644 index 0000000..d78a813 --- /dev/null +++ b/kifi-app/android/app/build.gradle.kts @@ -0,0 +1,50 @@ +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.sarascore.kifi_app" + compileSdk = 37 + ndkVersion = flutter.ndkVersion + + compileOptions { + isCoreLibraryDesugaringEnabled = true + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.sarascore.kifi_app" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + proguardFiles(getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro") + } + } +} + +dependencies { + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") +} + +flutter { + source = "../.." +} diff --git a/kifi-app/android/app/proguard-rules.pro b/kifi-app/android/app/proguard-rules.pro new file mode 100644 index 0000000..5c91193 --- /dev/null +++ b/kifi-app/android/app/proguard-rules.pro @@ -0,0 +1,4 @@ +-dontwarn com.google.mlkit.vision.text.chinese.** +-dontwarn com.google.mlkit.vision.text.devanagari.** +-dontwarn com.google.mlkit.vision.text.japanese.** +-dontwarn com.google.mlkit.vision.text.korean.** diff --git a/kifi-app/android/app/src/debug/AndroidManifest.xml b/kifi-app/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/kifi-app/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/kifi-app/android/app/src/main/AndroidManifest.xml b/kifi-app/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..411e205 --- /dev/null +++ b/kifi-app/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/kifi-app/android/app/src/main/kotlin/com/sarascore/kifi_app/MainActivity.kt b/kifi-app/android/app/src/main/kotlin/com/sarascore/kifi_app/MainActivity.kt new file mode 100644 index 0000000..8939ce7 --- /dev/null +++ b/kifi-app/android/app/src/main/kotlin/com/sarascore/kifi_app/MainActivity.kt @@ -0,0 +1,5 @@ +package com.sarascore.kifi_app + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/kifi-app/android/app/src/main/res/drawable-v21/launch_background.xml b/kifi-app/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/kifi-app/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/kifi-app/android/app/src/main/res/drawable/launch_background.xml b/kifi-app/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/kifi-app/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/kifi-app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/kifi-app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..1a8aef1 Binary files /dev/null and b/kifi-app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/kifi-app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/kifi-app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..0a51d78 Binary files /dev/null and b/kifi-app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/kifi-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/kifi-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..2bc75b1 Binary files /dev/null and b/kifi-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/kifi-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/kifi-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..19d8705 Binary files /dev/null and b/kifi-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/kifi-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/kifi-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..e446c0d Binary files /dev/null and b/kifi-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/kifi-app/android/app/src/main/res/values-night/styles.xml b/kifi-app/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/kifi-app/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/kifi-app/android/app/src/main/res/values/styles.xml b/kifi-app/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/kifi-app/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/kifi-app/android/app/src/profile/AndroidManifest.xml b/kifi-app/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/kifi-app/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/kifi-app/android/build.gradle.kts b/kifi-app/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/kifi-app/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/kifi-app/android/gradle.properties b/kifi-app/android/gradle.properties new file mode 100644 index 0000000..fbee1d8 --- /dev/null +++ b/kifi-app/android/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true diff --git a/kifi-app/android/gradle/wrapper/gradle-wrapper.properties b/kifi-app/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..02767eb --- /dev/null +++ b/kifi-app/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip diff --git a/kifi-app/android/settings.gradle.kts b/kifi-app/android/settings.gradle.kts new file mode 100644 index 0000000..ca7fe06 --- /dev/null +++ b/kifi-app/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.11.1" apply false + id("org.jetbrains.kotlin.android") version "2.2.20" apply false +} + +include(":app") diff --git a/kifi-app/assets/icon.png b/kifi-app/assets/icon.png new file mode 100644 index 0000000..fb1458a Binary files /dev/null and b/kifi-app/assets/icon.png differ diff --git a/kifi-app/flutter_launcher_icons.yaml b/kifi-app/flutter_launcher_icons.yaml new file mode 100644 index 0000000..242e426 --- /dev/null +++ b/kifi-app/flutter_launcher_icons.yaml @@ -0,0 +1,4 @@ +flutter_icons: + android: true + ios: true + image_path: "assets/icon.png" diff --git a/kifi-app/ios/.gitignore b/kifi-app/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/kifi-app/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/kifi-app/ios/Flutter/AppFrameworkInfo.plist b/kifi-app/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..1dc6cf7 --- /dev/null +++ b/kifi-app/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 13.0 + + diff --git a/kifi-app/ios/Flutter/Debug.xcconfig b/kifi-app/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..ec97fc6 --- /dev/null +++ b/kifi-app/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/kifi-app/ios/Flutter/Release.xcconfig b/kifi-app/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..c4855bf --- /dev/null +++ b/kifi-app/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/kifi-app/ios/Podfile b/kifi-app/ios/Podfile new file mode 100644 index 0000000..3853408 --- /dev/null +++ b/kifi-app/ios/Podfile @@ -0,0 +1,46 @@ +# Uncomment this line to define a global platform for your project +platform :ios, '15.5' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + target.build_configurations.each do |config| + config.build_settings.delete 'EXCLUDED_ARCHS' + end + end +end diff --git a/kifi-app/ios/Podfile.lock b/kifi-app/ios/Podfile.lock new file mode 100644 index 0000000..b4b5805 --- /dev/null +++ b/kifi-app/ios/Podfile.lock @@ -0,0 +1,179 @@ +PODS: + - Flutter (1.0.0) + - flutter_image_compress_common (1.0.0): + - Flutter + - SDWebImage + - SDWebImageWebPCoder + - flutter_local_notifications (0.0.1): + - Flutter + - flutter_secure_storage_darwin (10.0.0): + - Flutter + - FlutterMacOS + - google_mlkit_commons (0.12.0): + - Flutter + - MLKitVision (~> 10.0.0) + - google_mlkit_text_recognition (0.16.0): + - Flutter + - google_mlkit_commons + - GoogleMLKit/TextRecognition (~> 9.0.0) + - GoogleDataTransport (10.1.1): + - nanopb (~> 3.30910.0) + - PromisesObjC (~> 2.4) + - GoogleMLKit/MLKitCore (9.0.0): + - MLKitCommon (~> 14.0.0) + - GoogleMLKit/TextRecognition (9.0.0): + - GoogleMLKit/MLKitCore + - MLKitTextRecognition (~> 7.0.0) + - GoogleToolboxForMac/Defines (4.2.1) + - GoogleToolboxForMac/Logger (4.2.1): + - GoogleToolboxForMac/Defines (= 4.2.1) + - "GoogleToolboxForMac/NSData+zlib (4.2.1)": + - GoogleToolboxForMac/Defines (= 4.2.1) + - GoogleUtilities/Environment (8.1.2): + - GoogleUtilities/Privacy + - GoogleUtilities/Logger (8.1.2): + - GoogleUtilities/Environment + - GoogleUtilities/Privacy + - GoogleUtilities/Privacy (8.1.2) + - GoogleUtilities/UserDefaults (8.1.2): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy + - GTMSessionFetcher/Core (3.5.0) + - image_picker_ios (0.0.1): + - Flutter + - libwebp (1.6.0): + - libwebp/demux (= 1.6.0) + - libwebp/mux (= 1.6.0) + - libwebp/sharpyuv (= 1.6.0) + - libwebp/webp (= 1.6.0) + - libwebp/demux (1.6.0): + - libwebp/webp + - libwebp/mux (1.6.0): + - libwebp/demux + - libwebp/sharpyuv (1.6.0) + - libwebp/webp (1.6.0): + - libwebp/sharpyuv + - local_auth_darwin (0.0.1): + - Flutter + - FlutterMacOS + - MLImage (1.0.0-beta8) + - MLKitCommon (14.0.0): + - GoogleDataTransport (~> 10.0) + - GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1) + - "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)" + - GoogleUtilities/Logger (~> 8.0) + - GoogleUtilities/UserDefaults (~> 8.0) + - GTMSessionFetcher/Core (< 4.0, >= 3.3.2) + - MLKitTextRecognition (7.0.0): + - MLKitCommon (~> 14.0) + - MLKitTextRecognitionCommon (= 6.0.0) + - MLKitVision (~> 10.0) + - MLKitTextRecognitionCommon (6.0.0): + - MLKitCommon (~> 14.0) + - MLKitVision (~> 10.0) + - MLKitVision (10.0.0): + - GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1) + - "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)" + - GTMSessionFetcher/Core (< 4.0, >= 3.3.2) + - MLImage (= 1.0.0-beta8) + - MLKitCommon (~> 14.0) + - nanopb (3.30910.0): + - nanopb/decode (= 3.30910.0) + - nanopb/encode (= 3.30910.0) + - nanopb/decode (3.30910.0) + - nanopb/encode (3.30910.0) + - PromisesObjC (2.4.1) + - SDWebImage (5.21.7): + - SDWebImage/Core (= 5.21.7) + - SDWebImage/Core (5.21.7) + - SDWebImageWebPCoder (0.15.0): + - libwebp (~> 1.0) + - SDWebImage/Core (~> 5.17) + - share_plus (0.0.1): + - Flutter + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + +DEPENDENCIES: + - Flutter (from `Flutter`) + - flutter_image_compress_common (from `.symlinks/plugins/flutter_image_compress_common/ios`) + - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) + - flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`) + - google_mlkit_commons (from `.symlinks/plugins/google_mlkit_commons/ios`) + - google_mlkit_text_recognition (from `.symlinks/plugins/google_mlkit_text_recognition/ios`) + - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) + - local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`) + - share_plus (from `.symlinks/plugins/share_plus/ios`) + - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) + +SPEC REPOS: + trunk: + - GoogleDataTransport + - GoogleMLKit + - GoogleToolboxForMac + - GoogleUtilities + - GTMSessionFetcher + - libwebp + - MLImage + - MLKitCommon + - MLKitTextRecognition + - MLKitTextRecognitionCommon + - MLKitVision + - nanopb + - PromisesObjC + - SDWebImage + - SDWebImageWebPCoder + +EXTERNAL SOURCES: + Flutter: + :path: Flutter + flutter_image_compress_common: + :path: ".symlinks/plugins/flutter_image_compress_common/ios" + flutter_local_notifications: + :path: ".symlinks/plugins/flutter_local_notifications/ios" + flutter_secure_storage_darwin: + :path: ".symlinks/plugins/flutter_secure_storage_darwin/darwin" + google_mlkit_commons: + :path: ".symlinks/plugins/google_mlkit_commons/ios" + google_mlkit_text_recognition: + :path: ".symlinks/plugins/google_mlkit_text_recognition/ios" + image_picker_ios: + :path: ".symlinks/plugins/image_picker_ios/ios" + local_auth_darwin: + :path: ".symlinks/plugins/local_auth_darwin/darwin" + share_plus: + :path: ".symlinks/plugins/share_plus/ios" + shared_preferences_foundation: + :path: ".symlinks/plugins/shared_preferences_foundation/darwin" + +SPEC CHECKSUMS: + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + flutter_image_compress_common: 11d5dcfb36f4ded92320bfa896261813a073240e + flutter_local_notifications: 643a3eda1ce1c0599413ca31672536d423dee214 + flutter_secure_storage_darwin: 46e401699982ee74142909676535e0f6a7321e58 + google_mlkit_commons: b4dfe2f6ae4cbf24010dc6640a28984d01343c86 + google_mlkit_text_recognition: 9b164471713e3afdb4c129cabe6d1d64fef7e817 + GoogleDataTransport: a24e58982ab3ba2f64d79613e027fe7f57e88539 + GoogleMLKit: b1eee21a41c57704fe72483b15c85cb2c0cd7444 + GoogleToolboxForMac: d1a2cbf009c453f4d6ded37c105e2f67a32206d8 + GoogleUtilities: 766ace00c6b10d8148408f329d10c4f051931850 + GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6 + image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326 + libwebp: af5937a13536ef73f3784d69cc342b98961acf33 + local_auth_darwin: c3ee6cce0a8d56be34c8ccb66ba31f7f180aaebb + MLImage: 0de5c6c2bf9e93b80ef752e2797f0836f03b58c0 + MLKitCommon: 47d47b50a031d00db62f1b0efe5a1d8b09a3b2e6 + MLKitTextRecognition: c0ad24510481dfc8893ad15dfcb8a5f05ed9e826 + MLKitTextRecognitionCommon: 234ceb1cfdfb5fceb4fd664943046609a2961cc2 + MLKitVision: 39a5a812db83c4a0794445088e567f3631c11961 + nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 + PromisesObjC: 752c3227f599e3467650e47ea36f433eeb10c273 + SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf + SDWebImageWebPCoder: 0e06e365080397465cc73a7a9b472d8a3bd0f377 + share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a + shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb + +PODFILE CHECKSUM: 05bcc0ae31139d41b9fa5949f7a59ee09c6fb20d + +COCOAPODS: 1.17.0 diff --git a/kifi-app/ios/Runner.xcodeproj/project.pbxproj b/kifi-app/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..16deac4 --- /dev/null +++ b/kifi-app/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,751 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 312D2FA747F4BF324094ED55 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F06C0555F49CA279B7F03E87 /* Pods_RunnerTests.framework */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 52339C7D96FB484157166BA2 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 10E322288A433A3444E6480D /* Pods_Runner.framework */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 10E322288A433A3444E6480D /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 3CB337752F0813F6923ADC49 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 47C12038BB6EBB708F74F4C5 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 94C8738DA289CD156F426C3C /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + A1AD163901F88FB7876836C0 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + A1F0AAF9706AEFA06CC31D9A /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + F06C0555F49CA279B7F03E87 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + F315CD68535668FE5A36584C /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 7E550CFF6B1E014E5F6406DB /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 312D2FA747F4BF324094ED55 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 52339C7D96FB484157166BA2 /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 53DF7287B9D01C6A8EF8654D /* Frameworks */ = { + isa = PBXGroup; + children = ( + 10E322288A433A3444E6480D /* Pods_Runner.framework */, + F06C0555F49CA279B7F03E87 /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + A53102319B25770F648A9197 /* Pods */, + 53DF7287B9D01C6A8EF8654D /* Frameworks */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; + A53102319B25770F648A9197 /* Pods */ = { + isa = PBXGroup; + children = ( + 3CB337752F0813F6923ADC49 /* Pods-Runner.debug.xcconfig */, + 47C12038BB6EBB708F74F4C5 /* Pods-Runner.release.xcconfig */, + A1F0AAF9706AEFA06CC31D9A /* Pods-Runner.profile.xcconfig */, + 94C8738DA289CD156F426C3C /* Pods-RunnerTests.debug.xcconfig */, + A1AD163901F88FB7876836C0 /* Pods-RunnerTests.release.xcconfig */, + F315CD68535668FE5A36584C /* Pods-RunnerTests.profile.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 303776586152ACB29BA42786 /* [CP] Check Pods Manifest.lock */, + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + 7E550CFF6B1E014E5F6406DB /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + C9C52B4D2082ADD2599053FA /* [CP] Check Pods Manifest.lock */, + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + 9A9CAD73968105EABAD607AB /* [CP] Embed Pods Frameworks */, + 4F5F2B2DF690B64C3E6E3C86 /* [CP] Copy Pods Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 303776586152ACB29BA42786 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 4F5F2B2DF690B64C3E6E3C86 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; + 9A9CAD73968105EABAD607AB /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + C9C52B4D2082ADD2599053FA /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.5; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = GA7G8SBPKB; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.sarascore.kifiApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 94C8738DA289CD156F426C3C /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.sarascore.kifiApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = A1AD163901F88FB7876836C0 /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.sarascore.kifiApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = F315CD68535668FE5A36584C /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.sarascore.kifiApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.5; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.5; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = GA7G8SBPKB; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.sarascore.kifiApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = GA7G8SBPKB; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.sarascore.kifiApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/kifi-app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/kifi-app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/kifi-app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/kifi-app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/kifi-app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/kifi-app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/kifi-app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/kifi-app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/kifi-app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/kifi-app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/kifi-app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..e3773d4 --- /dev/null +++ b/kifi-app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/kifi-app/ios/Runner.xcworkspace/contents.xcworkspacedata b/kifi-app/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..21a3cc1 --- /dev/null +++ b/kifi-app/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/kifi-app/ios/Runner/AppDelegate.swift b/kifi-app/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..6266644 --- /dev/null +++ b/kifi-app/ios/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d0d98aa --- /dev/null +++ b/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1 @@ +{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}} \ No newline at end of file diff --git a/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..e82da53 Binary files /dev/null and b/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..7476c49 Binary files /dev/null and b/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..df4a9e0 Binary files /dev/null and b/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..0993958 Binary files /dev/null and b/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..114377c Binary files /dev/null and b/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..a50fe46 Binary files /dev/null and b/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..02c0078 Binary files /dev/null and b/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..df4a9e0 Binary files /dev/null and b/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..73ec712 Binary files /dev/null and b/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..978b561 Binary files /dev/null and b/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png new file mode 100644 index 0000000..e06fc08 Binary files /dev/null and b/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ diff --git a/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png new file mode 100644 index 0000000..f170e50 Binary files /dev/null and b/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ diff --git a/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png new file mode 100644 index 0000000..7016404 Binary files /dev/null and b/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ diff --git a/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png new file mode 100644 index 0000000..65669ad Binary files /dev/null and b/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png differ diff --git a/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..978b561 Binary files /dev/null and b/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..2de9f1b Binary files /dev/null and b/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png new file mode 100644 index 0000000..1a8aef1 Binary files /dev/null and b/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ diff --git a/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png new file mode 100644 index 0000000..19d8705 Binary files /dev/null and b/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ diff --git a/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..de57e20 Binary files /dev/null and b/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..4610dad Binary files /dev/null and b/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..dcb3541 Binary files /dev/null and b/kifi-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/kifi-app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/kifi-app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/kifi-app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/kifi-app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/kifi-app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/kifi-app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/kifi-app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/kifi-app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/kifi-app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/kifi-app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/kifi-app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/kifi-app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/kifi-app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/kifi-app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/kifi-app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/kifi-app/ios/Runner/Base.lproj/LaunchScreen.storyboard b/kifi-app/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/kifi-app/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/kifi-app/ios/Runner/Base.lproj/Main.storyboard b/kifi-app/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/kifi-app/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/kifi-app/ios/Runner/Info.plist b/kifi-app/ios/Runner/Info.plist new file mode 100644 index 0000000..f2a5fe0 --- /dev/null +++ b/kifi-app/ios/Runner/Info.plist @@ -0,0 +1,55 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Kifi App + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + kifi_app + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + CADisableMinimumFrameDurationOnPhone + + NSCameraUsageDescription + Used to scan receipts for auto-filling transaction details. + NSPhotoLibraryUsageDescription + Used to select receipts for auto-filling transaction details. + NSFaceIDUsageDescription + Authenticate to access your personal finance data securely. + UIApplicationSupportsIndirectInputEvents + + + diff --git a/kifi-app/ios/Runner/Runner-Bridging-Header.h b/kifi-app/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/kifi-app/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/kifi-app/ios/RunnerTests/RunnerTests.swift b/kifi-app/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/kifi-app/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/kifi-app/lib/core/network/dio_client.dart b/kifi-app/lib/core/network/dio_client.dart new file mode 100644 index 0000000..26e5989 --- /dev/null +++ b/kifi-app/lib/core/network/dio_client.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import '../../main.dart'; +import '../../features/auth/presentation/auth_screen.dart'; + +class DioClient { + static final DioClient _instance = DioClient._internal(); + final Dio dio; + final FlutterSecureStorage storage; + + factory DioClient() { + return _instance; + } + + DioClient._internal() + : dio = Dio(BaseOptions( + baseUrl: 'https://app.technobeesolutions.in/api/kifi', + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 10), + )), + storage = const FlutterSecureStorage() { + dio.interceptors.add(InterceptorsWrapper( + onRequest: (options, handler) async { + final token = await storage.read(key: 'jwt_token'); + if (token != null) { + options.headers['Authorization'] = 'Bearer $token'; + } + return handler.next(options); + }, + onError: (DioException error, handler) async { + if (error.response?.statusCode == 401) { + // Token is invalid or expired + await storage.delete(key: 'jwt_token'); + if (navigatorKey.currentContext != null) { + Navigator.of(navigatorKey.currentContext!).pushAndRemoveUntil( + MaterialPageRoute(builder: (_) => const AuthScreen()), + (route) => false, + ); + } + } + + String cleanMessage = "An unexpected error occurred."; + if (error.type == DioExceptionType.connectionTimeout || error.type == DioExceptionType.sendTimeout || error.type == DioExceptionType.receiveTimeout) { + cleanMessage = "Network error: Unable to connect to server. Please check your internet connection."; + } else if (error.response?.statusCode == 500) { + cleanMessage = "Server error. Please try again later."; + } else if (error.response?.data != null && error.response!.data is Map && error.response!.data['message'] != null) { + cleanMessage = error.response!.data['message']; + } else if (error.type == DioExceptionType.connectionError) { + cleanMessage = "Network error: Unable to connect to server."; + } + + return handler.reject( + DioException( + requestOptions: error.requestOptions, + error: CleanException(cleanMessage), + type: error.type, + response: error.response, + ) + ); + }, + )); + } +} + +class CleanException implements Exception { + final String message; + CleanException(this.message); + + @override + String toString() => message; +} diff --git a/kifi-app/lib/core/security/crypto_service.dart b/kifi-app/lib/core/security/crypto_service.dart new file mode 100644 index 0000000..b5e7391 --- /dev/null +++ b/kifi-app/lib/core/security/crypto_service.dart @@ -0,0 +1,38 @@ +import 'package:encrypt/encrypt.dart'; +import 'package:pointycastle/asymmetric/api.dart'; +import 'package:dio/dio.dart'; +import '../network/dio_client.dart'; + +class CryptoService { + static final CryptoService _instance = CryptoService._internal(); + factory CryptoService() => _instance; + CryptoService._internal(); + + RSAPublicKey? _publicKey; + + Future fetchPublicKey() async { + try { + final dio = Dio(BaseOptions( + baseUrl: DioClient().dio.options.baseUrl, + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 10), + )); + final response = await dio.get('/auth/public-key'); + final publicKeyBase64 = response.data['publicKey'] as String; + _publicKey = RSAKeyParser().parse('-----BEGIN PUBLIC KEY-----\n$publicKeyBase64\n-----END PUBLIC KEY-----') as RSAPublicKey; + } catch (e) { + throw Exception('Failed to fetch public key: $e'); + } + } + + String encrypt(String plainText) { + if (_publicKey == null) { + throw Exception('Public key not initialized. Call fetchPublicKey() first.'); + } + final encrypter = Encrypter(RSA(publicKey: _publicKey)); + final encrypted = encrypter.encrypt(plainText); + return encrypted.base64; + } + + bool get isInitialized => _publicKey != null; +} diff --git a/kifi-app/lib/core/services/notification_service.dart b/kifi-app/lib/core/services/notification_service.dart new file mode 100644 index 0000000..363f55c --- /dev/null +++ b/kifi-app/lib/core/services/notification_service.dart @@ -0,0 +1,76 @@ +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:timezone/data/latest_all.dart' as tz; +import 'package:timezone/timezone.dart' as tz; +import 'package:flutter/foundation.dart'; + +class NotificationService { + static final NotificationService _instance = NotificationService._internal(); + factory NotificationService() => _instance; + NotificationService._internal(); + + final FlutterLocalNotificationsPlugin _flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); + bool _initialized = false; + + Future initialize() async { + if (_initialized) return; + + tz.initializeTimeZones(); + + const AndroidInitializationSettings initializationSettingsAndroid = + AndroidInitializationSettings('@mipmap/ic_launcher'); + + final DarwinInitializationSettings initializationSettingsDarwin = + DarwinInitializationSettings( + requestAlertPermission: true, + requestBadgePermission: true, + requestSoundPermission: true, + ); + + final InitializationSettings initializationSettings = InitializationSettings( + android: initializationSettingsAndroid, + iOS: initializationSettingsDarwin, + ); + + await _flutterLocalNotificationsPlugin.initialize(settings: initializationSettings); + _initialized = true; + } + + Future scheduleNotification({ + required int id, + required String title, + required String body, + required DateTime scheduledDate, + }) async { + if (!_initialized) await initialize(); + + try { + await _flutterLocalNotificationsPlugin.zonedSchedule( + id: id, + title: title, + body: body, + scheduledDate: tz.TZDateTime.from(scheduledDate, tz.local), + notificationDetails: const NotificationDetails( + android: AndroidNotificationDetails( + 'payables_alerts_channel', + 'Payables Alerts', + channelDescription: 'Notifications for upcoming payable due dates', + importance: Importance.max, + priority: Priority.high, + ), + iOS: DarwinNotificationDetails( + presentAlert: true, + presentBadge: true, + presentSound: true, + ), + ), + androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, + ); + } catch (e) { + debugPrint('Error scheduling notification: $e'); + } + } + + Future cancelNotification(int id) async { + await _flutterLocalNotificationsPlugin.cancel(id: id); + } +} diff --git a/kifi-app/lib/core/services/ocr_service.dart b/kifi-app/lib/core/services/ocr_service.dart new file mode 100644 index 0000000..a449acd --- /dev/null +++ b/kifi-app/lib/core/services/ocr_service.dart @@ -0,0 +1,60 @@ +import 'dart:io'; +import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart'; +import 'package:image_picker/image_picker.dart'; + +class OcrResult { + final double? amount; + final String? rawText; + + OcrResult({this.amount, this.rawText}); +} + +class OcrService { + final ImagePicker _picker = ImagePicker(); + + Future scanReceiptFromCamera() async { + final XFile? image = await _picker.pickImage(source: ImageSource.camera); + if (image == null) return null; + return await _processImage(File(image.path)); + } + + Future scanReceiptFromGallery() async { + final XFile? image = await _picker.pickImage(source: ImageSource.gallery); + if (image == null) return null; + return await _processImage(File(image.path)); + } + + Future _processImage(File file) async { + final inputImage = InputImage.fromFile(file); + final textRecognizer = TextRecognizer(script: TextRecognitionScript.latin); + + try { + final RecognizedText recognizedText = await textRecognizer.processImage(inputImage); + String text = recognizedText.text; + + // Simple regex to find a monetary amount (e.g., $145.50 or 145.50) + final RegExp amountRegex = RegExp(r'\$?\d+\.\d{2}'); + final Iterable matches = amountRegex.allMatches(text); + + double? maxAmount; + for (final Match m in matches) { + final matchText = m.group(0)?.replaceAll('\$', ''); + if (matchText != null) { + final amount = double.tryParse(matchText); + if (amount != null) { + if (maxAmount == null || amount > maxAmount) { + maxAmount = amount; // Usually the total is the largest amount + } + } + } + } + + return OcrResult( + amount: maxAmount, + rawText: text, + ); + } finally { + textRecognizer.close(); + } + } +} diff --git a/kifi-app/lib/core/theme/app_theme.dart b/kifi-app/lib/core/theme/app_theme.dart new file mode 100644 index 0000000..e94b6d5 --- /dev/null +++ b/kifi-app/lib/core/theme/app_theme.dart @@ -0,0 +1,118 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +class AppTheme { + static const Color primaryColor = Color(0xFF6C63FF); + static const Color secondaryColor = Color(0xFFFF6584); + static const Color backgroundColor = Color(0xFFF7F9FC); + static const Color surfaceColor = Colors.white; + static const Color textPrimaryColor = Color(0xFF2D3142); + static const Color textSecondaryColor = Color(0xFF9094A6); + + static ThemeData get lightTheme { + return ThemeData( + useMaterial3: true, + colorScheme: const ColorScheme.light( + primary: primaryColor, + secondary: secondaryColor, + surface: surfaceColor, + onSurface: textPrimaryColor, + ), + scaffoldBackgroundColor: backgroundColor, + textTheme: GoogleFonts.outfitTextTheme().copyWith( + displayLarge: GoogleFonts.outfit(color: textPrimaryColor, fontWeight: FontWeight.bold, fontSize: 32), + bodyLarge: GoogleFonts.outfit(color: textPrimaryColor, fontSize: 16), + bodyMedium: GoogleFonts.outfit(color: textSecondaryColor, fontSize: 14), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: primaryColor, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + elevation: 0, + ), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: Colors.white, + contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide(color: primaryColor, width: 2), + ), + hintStyle: const TextStyle(color: textSecondaryColor), + ), + cardTheme: CardThemeData( + color: surfaceColor, + elevation: 2, + shadowColor: Colors.black.withValues(alpha: 0.05), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), + ), + ); + } + + static ThemeData get darkTheme { + const Color darkBackground = Color(0xFF121212); + const Color darkSurface = Color(0xFF1E1E1E); + const Color darkTextPrimary = Color(0xFFE0E0E0); + const Color darkTextSecondary = Color(0xFFA0A0A0); + + return ThemeData( + useMaterial3: true, + colorScheme: const ColorScheme.dark( + primary: primaryColor, + secondary: secondaryColor, + surface: darkSurface, + onSurface: darkTextPrimary, + ), + scaffoldBackgroundColor: darkBackground, + textTheme: GoogleFonts.outfitTextTheme().copyWith( + displayLarge: GoogleFonts.outfit(color: darkTextPrimary, fontWeight: FontWeight.bold, fontSize: 32), + bodyLarge: GoogleFonts.outfit(color: darkTextPrimary, fontSize: 16), + bodyMedium: GoogleFonts.outfit(color: darkTextSecondary, fontSize: 14), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: primaryColor, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + elevation: 0, + ), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: darkSurface, + contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide(color: primaryColor, width: 2), + ), + hintStyle: const TextStyle(color: darkTextSecondary), + ), + cardTheme: CardThemeData( + color: darkSurface, + elevation: 2, + shadowColor: Colors.black.withValues(alpha: 0.2), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), + ), + ); + } +} diff --git a/kifi-app/lib/core/theme/nature_colors.dart b/kifi-app/lib/core/theme/nature_colors.dart new file mode 100644 index 0000000..bcbeb6c --- /dev/null +++ b/kifi-app/lib/core/theme/nature_colors.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; + +class NatureColors { + static Color getColor(String nature) { + switch (nature) { + case 'INCOME': + return Colors.green.shade700; + case 'RECEIVABLES': + case 'LENDING': + return Colors.lightGreen; + case 'EXPENSE': + return Colors.red; + case 'PAYABLES': + case 'LOAN': + return Colors.orange; + case 'INVESTMENTS': + case 'INVESTMENT': + return Colors.blue; + case 'SAVINGS': + return Colors.teal; + case 'CASH': + case 'BALANCE': + return Colors.blueGrey; + case 'TRANSFER': + return Colors.blueGrey; + default: + return Colors.grey; + } + } + + static List getPalette(String nature) { + switch (nature) { + case 'INCOME': + return [Colors.green.shade900, Colors.green.shade700, Colors.green.shade500, Colors.green.shade300, Colors.greenAccent.shade700, Colors.teal.shade400, Colors.lightGreen.shade400]; + case 'RECEIVABLES': + case 'LENDING': + return [Colors.lightGreen.shade900, Colors.lightGreen.shade700, Colors.lightGreen, Colors.lime.shade600, Colors.lightGreenAccent.shade700, Colors.green.shade400]; + case 'EXPENSE': + return [Colors.red.shade900, Colors.red.shade700, Colors.red, Colors.deepOrange.shade400, Colors.pink.shade400, Colors.redAccent.shade700, Colors.orange.shade800]; + case 'PAYABLES': + case 'LOAN': + return [Colors.orange.shade900, Colors.orange.shade700, Colors.orange, Colors.deepOrange.shade600, Colors.amber.shade700, Colors.orangeAccent.shade700, Colors.brown.shade400]; + case 'INVESTMENTS': + case 'INVESTMENT': + return [Colors.blue.shade900, Colors.blue.shade700, Colors.blue, Colors.lightBlue.shade600, Colors.cyan.shade600, Colors.blueAccent.shade700, Colors.indigo.shade400]; + default: + return [Colors.blueGrey.shade800, Colors.blueGrey.shade600, Colors.blueGrey, Colors.grey.shade600, Colors.grey.shade400, Colors.black54]; + } + } +} diff --git a/kifi-app/lib/core/theme/theme_provider.dart b/kifi-app/lib/core/theme/theme_provider.dart new file mode 100644 index 0000000..6fc0168 --- /dev/null +++ b/kifi-app/lib/core/theme/theme_provider.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class ThemeNotifier extends Notifier { + static const _themePrefKey = 'theme_pref'; + + @override + ThemeMode build() { + _loadTheme(); + return ThemeMode.system; + } + + Future _loadTheme() async { + final prefs = await SharedPreferences.getInstance(); + final themeString = prefs.getString(_themePrefKey); + if (themeString != null) { + if (themeString == 'light') { + state = ThemeMode.light; + } else if (themeString == 'dark') { + state = ThemeMode.dark; + } + } + } + + Future setTheme(ThemeMode mode) async { + state = mode; + final prefs = await SharedPreferences.getInstance(); + if (mode == ThemeMode.light) { + await prefs.setString(_themePrefKey, 'light'); + } else if (mode == ThemeMode.dark) { + await prefs.setString(_themePrefKey, 'dark'); + } else { + await prefs.setString(_themePrefKey, 'system'); + } + } +} + +final themeProvider = NotifierProvider(() { + return ThemeNotifier(); +}); diff --git a/kifi-app/lib/core/utils/snackbar_service.dart b/kifi-app/lib/core/utils/snackbar_service.dart new file mode 100644 index 0000000..359ecb9 --- /dev/null +++ b/kifi-app/lib/core/utils/snackbar_service.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons/lucide_icons.dart'; + +class SnackBarService { + static void showSuccess(BuildContext context, String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + const Icon(LucideIcons.checkCircle, color: Colors.white), + const SizedBox(width: 12), + Expanded(child: Text(message)), + ], + ), + backgroundColor: Colors.green.shade600, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + margin: const EdgeInsets.all(16), + ), + ); + } + + static void showError(BuildContext context, String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + const Icon(LucideIcons.alertCircle, color: Colors.white), + const SizedBox(width: 12), + Expanded(child: Text(message)), + ], + ), + backgroundColor: Colors.red.shade600, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + margin: const EdgeInsets.all(16), + ), + ); + } + + static void showWarning(BuildContext context, String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + const Icon(LucideIcons.alertTriangle, color: Colors.white), + const SizedBox(width: 12), + Expanded(child: Text(message)), + ], + ), + backgroundColor: Colors.orange.shade700, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + margin: const EdgeInsets.all(16), + duration: const Duration(seconds: 4), + ), + ); + } +} diff --git a/kifi-app/lib/core/widgets/empty_state.dart b/kifi-app/lib/core/widgets/empty_state.dart new file mode 100644 index 0000000..ecae99f --- /dev/null +++ b/kifi-app/lib/core/widgets/empty_state.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; + +class EmptyStateWidget extends StatelessWidget { + final IconData icon; + final String title; + final String message; + + const EmptyStateWidget({ + super.key, + required this.icon, + required this.title, + required this.message, + }); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icon, + size: 80, + color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.5), + ), + const SizedBox(height: 24), + Text( + title, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + Text( + message, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + ], + ), + ), + ); + } +} diff --git a/kifi-app/lib/core/widgets/shimmer_loading.dart b/kifi-app/lib/core/widgets/shimmer_loading.dart new file mode 100644 index 0000000..8960ad2 --- /dev/null +++ b/kifi-app/lib/core/widgets/shimmer_loading.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'package:shimmer/shimmer.dart'; + +class ShimmerLoading extends StatelessWidget { + final double width; + final double height; + final double borderRadius; + + const ShimmerLoading({ + super.key, + required this.width, + required this.height, + this.borderRadius = 8.0, + }); + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + + return Shimmer.fromColors( + baseColor: isDark ? Colors.grey.shade800 : Colors.grey.shade300, + highlightColor: isDark ? Colors.grey.shade700 : Colors.grey.shade100, + child: Container( + width: width, + height: height, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(borderRadius), + ), + ), + ); + } +} + +class ShimmerCard extends StatelessWidget { + const ShimmerCard({super.key}); + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const ShimmerLoading(width: 120, height: 16), + const SizedBox(height: 16), + const ShimmerLoading(width: double.infinity, height: 24), + const SizedBox(height: 8), + const ShimmerLoading(width: double.infinity, height: 24), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: const [ + ShimmerLoading(width: 60, height: 16), + ShimmerLoading(width: 60, height: 16), + ], + ) + ], + ), + ), + ); + } +} diff --git a/kifi-app/lib/features/auth/data/auth_repository.dart b/kifi-app/lib/features/auth/data/auth_repository.dart new file mode 100644 index 0000000..88fca49 --- /dev/null +++ b/kifi-app/lib/features/auth/data/auth_repository.dart @@ -0,0 +1,84 @@ +import 'package:dio/dio.dart'; +import '../../../../core/network/dio_client.dart'; +import '../../../../core/security/crypto_service.dart'; + +class AuthRepository { + final Dio dio = DioClient().dio; + final CryptoService _crypto = CryptoService(); + + Future _ensureCryptoReady() async { + if (!_crypto.isInitialized) { + await _crypto.fetchPublicKey(); + } + } + + Future signup(String email, String password) async { + try { + await _ensureCryptoReady(); + await dio.post('/auth/signup', data: { + 'email': _crypto.encrypt(email), + 'password': _crypto.encrypt(password), + }); + } on DioException catch (e) { + final data = e.response?.data; + final errorMsg = data is Map ? data['error'] : data; + throw Exception(errorMsg ?? e.message); + } + } + + Future> login(String email, String password) async { + try { + await _ensureCryptoReady(); + final response = await dio.post('/auth/login', data: { + 'email': _crypto.encrypt(email), + 'password': _crypto.encrypt(password), + }); + return response.data; + } on DioException catch (e) { + final data = e.response?.data; + final errorMsg = data is Map ? data['error'] : data; + throw Exception(errorMsg ?? 'Invalid email or password'); + } + } + + Future> verifyOtp(String email, String otp) async { + try { + final response = await dio.post('/auth/verify-otp', data: { + 'email': email, + 'otp': otp, + }); + return response.data; + } on DioException catch (e) { + final data = e.response?.data; + final errorMsg = data is Map ? data['error'] : data; + throw Exception(errorMsg ?? 'Invalid OTP'); + } + } + + Future forgotPassword(String email) async { + try { + await dio.post('/auth/forgot-password', data: { + 'email': email, + }); + } on DioException catch (e) { + final data = e.response?.data; + final errorMsg = data is Map ? data['error'] : data; + throw Exception(errorMsg ?? 'Failed to send reset code'); + } + } + + Future resetPassword(String email, String otp, String newPassword) async { + try { + await _ensureCryptoReady(); + await dio.post('/auth/reset-password', data: { + 'email': email, + 'otp': otp, + 'newPassword': _crypto.encrypt(newPassword), + }); + } on DioException catch (e) { + final data = e.response?.data; + final errorMsg = data is Map ? data['error'] : data; + throw Exception(errorMsg ?? 'Failed to reset password'); + } + } +} diff --git a/kifi-app/lib/features/auth/presentation/auth_screen.dart b/kifi-app/lib/features/auth/presentation/auth_screen.dart new file mode 100644 index 0000000..b9b39cd --- /dev/null +++ b/kifi-app/lib/features/auth/presentation/auth_screen.dart @@ -0,0 +1,153 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons/lucide_icons.dart'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../providers/auth_provider.dart'; +import 'otp_screen.dart'; +import 'forgot_password_screen.dart'; +import '../../dashboard/presentation/dashboard_screen.dart'; + +class AuthScreen extends ConsumerStatefulWidget { + const AuthScreen({super.key}); + + @override + ConsumerState createState() => _AuthScreenState(); +} + +class _AuthScreenState extends ConsumerState { + bool isLogin = true; + final emailController = TextEditingController(); + final passwordController = TextEditingController(); + + void toggleMode() { + setState(() { + isLogin = !isLogin; + }); + } + + Future submit() async { + final email = emailController.text.trim(); + final password = passwordController.text; + + if (email.isEmpty || password.isEmpty) return; + + if (isLogin) { + final success = await ref.read(authControllerProvider.notifier).login(email, password); + if (success && mounted) { + Navigator.pushReplacement( + context, MaterialPageRoute(builder: (_) => const DashboardScreen())); + } + } else { + final success = await ref.read(authControllerProvider.notifier).signup(email, password); + if (success && mounted) { + Navigator.push(context, MaterialPageRoute(builder: (_) => OtpScreen(email: email))); + } + } + } + + @override + Widget build(BuildContext context) { + ref.listen>(authControllerProvider, (previous, next) { + if (next.hasError) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: ${next.error}')), + ); + } + }); + + final state = ref.watch(authControllerProvider); + final isLoading = state.isLoading; + + return Scaffold( + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + isLogin ? 'Welcome Back!' : 'Create Account', + style: Theme.of(context).textTheme.displayLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + isLogin ? 'Login to continue to Kifi' : 'Sign up to manage your expenses', + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 48), + TextField( + controller: emailController, + keyboardType: TextInputType.emailAddress, + style: const TextStyle(fontWeight: FontWeight.w500), + decoration: InputDecoration( + hintText: 'Email Address', + prefixIcon: Icon(LucideIcons.mail, color: Colors.grey), + filled: true, + fillColor: Colors.grey.shade100, + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)), + ), + ), + const SizedBox(height: 16), + TextField( + controller: passwordController, + obscureText: true, + style: const TextStyle(fontWeight: FontWeight.w500), + decoration: InputDecoration( + hintText: 'Password', + prefixIcon: Icon(LucideIcons.lock, color: Colors.grey), + filled: true, + fillColor: Colors.grey.shade100, + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)), + ), + ), + if (isLogin) ...[ + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: () { + Navigator.push(context, MaterialPageRoute(builder: (_) => const ForgotPasswordScreen())); + }, + style: TextButton.styleFrom( + foregroundColor: const Color(0xFF6C63FF), + padding: EdgeInsets.zero, + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: const Text('Forgot Password?', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 13)), + ), + ), + ], + const SizedBox(height: 24), + ElevatedButton( + onPressed: isLoading ? null : submit, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF6C63FF), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + elevation: 0, + ), + child: isLoading + ? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) + : Text(isLogin ? 'Login' : 'Sign Up', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + ), + const SizedBox(height: 16), + TextButton( + onPressed: toggleMode, + child: Text(isLogin ? 'Don\'t have an account? Sign Up' : 'Already have an account? Login'), + ), + ], + ), + ), + ), + ); + } +} diff --git a/kifi-app/lib/features/auth/presentation/forgot_password_screen.dart b/kifi-app/lib/features/auth/presentation/forgot_password_screen.dart new file mode 100644 index 0000000..3d2b602 --- /dev/null +++ b/kifi-app/lib/features/auth/presentation/forgot_password_screen.dart @@ -0,0 +1,164 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import '../providers/auth_provider.dart'; +import 'reset_password_screen.dart'; + +class ForgotPasswordScreen extends ConsumerStatefulWidget { + const ForgotPasswordScreen({super.key}); + + @override + ConsumerState createState() => _ForgotPasswordScreenState(); +} + +class _ForgotPasswordScreenState extends ConsumerState { + final emailController = TextEditingController(); + bool _isLoading = false; + String? _errorMessage; + + @override + void dispose() { + emailController.dispose(); + super.dispose(); + } + + Future _sendOtp() async { + final email = emailController.text.trim(); + if (email.isEmpty || !email.contains('@')) { + setState(() => _errorMessage = 'Please enter a valid email address'); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + final success = await ref.read(authControllerProvider.notifier).forgotPassword(email); + + if (mounted) { + setState(() => _isLoading = false); + if (success) { + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (_) => ResetPasswordScreen(email: email)), + ); + } else { + setState(() => _errorMessage = 'Failed to send reset code. Please check your email.'); + } + } + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () => FocusScope.of(context).unfocus(), + child: Scaffold( + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 12), + IconButton( + onPressed: () => Navigator.pop(context), + icon: const Icon(Icons.arrow_back_ios_new, size: 20), + style: IconButton.styleFrom( + backgroundColor: Colors.grey.shade100, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + const SizedBox(height: 40), + Center( + child: Container( + width: 80, + height: 80, + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF6C63FF), Color(0xFF48C6EF)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(24), + ), + child: const Icon(LucideIcons.keyRound, color: Colors.white, size: 36), + ), + ), + const SizedBox(height: 32), + const Center( + child: Text( + 'Forgot Password?', + style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold), + ), + ), + const SizedBox(height: 12), + Center( + child: Text( + 'Enter your email address and we\'ll send you\na verification code to reset your password.', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 15, color: Colors.grey.shade600, height: 1.5), + ), + ), + const SizedBox(height: 48), + + TextField( + controller: emailController, + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.done, + onSubmitted: (_) => _sendOtp(), + style: const TextStyle(fontWeight: FontWeight.w500), + decoration: InputDecoration( + hintText: 'Email Address', + prefixIcon: Icon(LucideIcons.mail, color: Colors.grey.shade500), + filled: true, + fillColor: Colors.grey.shade100, + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)), + ), + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Colors.red.shade50, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.red.shade200), + ), + child: Row( + children: [ + Icon(LucideIcons.alertCircle, color: Colors.red.shade400, size: 18), + const SizedBox(width: 10), + Expanded(child: Text(_errorMessage!, style: TextStyle(color: Colors.red.shade700, fontSize: 13))), + ], + ), + ), + ], + const SizedBox(height: 32), + + SizedBox( + width: double.infinity, + height: 56, + child: ElevatedButton( + onPressed: _isLoading ? null : _sendOtp, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF6C63FF), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + elevation: 0, + ), + child: _isLoading + ? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) + : const Text('Send Reset Code', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/kifi-app/lib/features/auth/presentation/otp_screen.dart b/kifi-app/lib/features/auth/presentation/otp_screen.dart new file mode 100644 index 0000000..e9661f7 --- /dev/null +++ b/kifi-app/lib/features/auth/presentation/otp_screen.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../providers/auth_provider.dart'; +import '../../dashboard/presentation/dashboard_screen.dart'; + +class OtpScreen extends ConsumerStatefulWidget { + final String email; + const OtpScreen({super.key, required this.email}); + + @override + ConsumerState createState() => _OtpScreenState(); +} + +class _OtpScreenState extends ConsumerState { + final otpController = TextEditingController(); + + Future verify() async { + final otp = otpController.text.trim(); + if (otp.isEmpty) return; + + final success = await ref.read(authControllerProvider.notifier).verifyOtp(widget.email, otp); + if (success && mounted) { + Navigator.pushAndRemoveUntil( + context, MaterialPageRoute(builder: (_) => const DashboardScreen()), (route) => false); + } + } + + @override + Widget build(BuildContext context) { + ref.listen>(authControllerProvider, (previous, next) { + if (next.hasError) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: ${next.error}')), + ); + } + }); + + final state = ref.watch(authControllerProvider); + final isLoading = state.isLoading; + + return Scaffold( + appBar: AppBar(backgroundColor: Colors.transparent, elevation: 0), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Verify Email', + style: Theme.of(context).textTheme.displayLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'Enter the 6-digit OTP sent to ${widget.email}', + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 48), + TextField( + controller: otpController, + keyboardType: TextInputType.number, + textAlign: TextAlign.center, + maxLength: 6, + style: const TextStyle(fontSize: 24, letterSpacing: 8, fontWeight: FontWeight.bold, color: Color(0xFF6C63FF)), + decoration: InputDecoration( + hintText: '000000', + hintStyle: TextStyle(letterSpacing: 8, color: Colors.grey.shade400), + filled: true, + fillColor: Colors.grey.shade100, + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)), + ), + ), + const SizedBox(height: 32), + ElevatedButton( + onPressed: isLoading ? null : verify, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF6C63FF), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + elevation: 0, + ), + child: isLoading + ? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) + : const Text('Verify', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + ), + ], + ), + ), + ), + ); + } +} diff --git a/kifi-app/lib/features/auth/presentation/profile_screen.dart b/kifi-app/lib/features/auth/presentation/profile_screen.dart new file mode 100644 index 0000000..00157e1 --- /dev/null +++ b/kifi-app/lib/features/auth/presentation/profile_screen.dart @@ -0,0 +1,148 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:cross_file/cross_file.dart'; +import 'auth_screen.dart'; +import '../../../core/network/dio_client.dart'; +import '../providers/auth_provider.dart'; +import '../../transactions/providers/providers.dart'; + +import '../../../core/theme/theme_provider.dart'; + +class ProfileScreen extends ConsumerStatefulWidget { + const ProfileScreen({super.key}); + + @override + ConsumerState createState() => _ProfileScreenState(); +} + +class _ProfileScreenState extends ConsumerState { + bool _isExporting = false; + + Future _logout(BuildContext context) async { + const storage = FlutterSecureStorage(); + await storage.delete(key: 'jwt_token'); + + if (context.mounted) { + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute(builder: (_) => const AuthScreen()), + (route) => false, + ); + } + } + + Future _exportData() async { + setState(() => _isExporting = true); + try { + final bytes = await ref.read(apiRepositoryProvider).exportTransactions(); + + final tempDir = await getTemporaryDirectory(); + final file = File('${tempDir.path}/transactions_export.csv'); + await file.writeAsBytes(bytes); + + final xFile = XFile(file.path); + await Share.shareXFiles([xFile], text: 'Here is my Kifi transactions export.'); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Export failed: $e'))); + } + } finally { + if (mounted) { + setState(() => _isExporting = false); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Profile'), + backgroundColor: Colors.transparent, + elevation: 0, + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const SizedBox(height: 20), + CircleAvatar( + radius: 50, + backgroundColor: Theme.of(context).colorScheme.primary.withOpacity(0.1), + child: Icon(LucideIcons.user, size: 50, color: Theme.of(context).colorScheme.primary), + ), + const SizedBox(height: 24), + const Text('Kifi User', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + const Text('maddy23285@gmail.com', style: TextStyle(color: Colors.grey, fontSize: 16)), + const SizedBox(height: 48), + Card( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: Column( + children: [ + ListTile( + leading: const Icon(LucideIcons.downloadCloud), + title: const Text('Export Data to CSV'), + trailing: _isExporting + ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(LucideIcons.chevronRight), + onTap: _isExporting ? null : _exportData, + ), + const Divider(height: 1), + ListTile( + leading: const Icon(LucideIcons.moon), + title: const Text('Theme'), + trailing: SegmentedButton( + segments: const [ + ButtonSegment(value: ThemeMode.light, icon: Icon(LucideIcons.sun)), + ButtonSegment(value: ThemeMode.system, icon: Icon(LucideIcons.monitor)), + ButtonSegment(value: ThemeMode.dark, icon: Icon(LucideIcons.moon)), + ], + selected: {ref.watch(themeProvider)}, + onSelectionChanged: (Set newSelection) { + ref.read(themeProvider.notifier).setTheme(newSelection.first); + }, + showSelectedIcon: false, + style: ButtonStyle(visualDensity: VisualDensity.compact), + ), + ), + const Divider(height: 1), + + ListTile( + leading: const Icon(LucideIcons.helpCircle), + title: const Text('Help & Support'), + trailing: const Icon(LucideIcons.chevronRight), + onTap: () {}, + ), + ], + ), + ), + const Spacer(), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () => _logout(context), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red.withOpacity(0.1), + foregroundColor: Colors.red, + padding: const EdgeInsets.symmetric(vertical: 16), + ), + icon: const Icon(LucideIcons.logOut), + label: const Text('Log Out', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + ), + ), + const SizedBox(height: 20), + ], + ), + ), + ), + ); + } +} diff --git a/kifi-app/lib/features/auth/presentation/reset_password_screen.dart b/kifi-app/lib/features/auth/presentation/reset_password_screen.dart new file mode 100644 index 0000000..40cb66f --- /dev/null +++ b/kifi-app/lib/features/auth/presentation/reset_password_screen.dart @@ -0,0 +1,238 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import '../providers/auth_provider.dart'; +import 'auth_screen.dart'; + +class ResetPasswordScreen extends ConsumerStatefulWidget { + final String email; + const ResetPasswordScreen({super.key, required this.email}); + + @override + ConsumerState createState() => _ResetPasswordScreenState(); +} + +class _ResetPasswordScreenState extends ConsumerState { + final otpController = TextEditingController(); + final newPasswordController = TextEditingController(); + final confirmPasswordController = TextEditingController(); + bool _isLoading = false; + bool _obscurePassword = true; + bool _obscureConfirm = true; + String? _errorMessage; + + @override + void dispose() { + otpController.dispose(); + newPasswordController.dispose(); + confirmPasswordController.dispose(); + super.dispose(); + } + + Future _resetPassword() async { + final otp = otpController.text.trim(); + final newPassword = newPasswordController.text; + final confirmPassword = confirmPasswordController.text; + + if (otp.isEmpty || otp.length != 6) { + setState(() => _errorMessage = 'Please enter a valid 6-digit OTP'); + return; + } + if (newPassword.isEmpty || newPassword.length < 6) { + setState(() => _errorMessage = 'Password must be at least 6 characters'); + return; + } + if (newPassword != confirmPassword) { + setState(() => _errorMessage = 'Passwords do not match'); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + final success = await ref.read(authControllerProvider.notifier).resetPassword(widget.email, otp, newPassword); + + if (mounted) { + setState(() => _isLoading = false); + if (success) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Password reset successfully! Please log in.'), + backgroundColor: Color(0xFF6C63FF), + ), + ); + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute(builder: (_) => const AuthScreen()), + (route) => false, + ); + } else { + setState(() => _errorMessage = 'Invalid OTP or reset failed. Please try again.'); + } + } + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () => FocusScope.of(context).unfocus(), + child: Scaffold( + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 12), + IconButton( + onPressed: () => Navigator.pop(context), + icon: const Icon(Icons.arrow_back_ios_new, size: 20), + style: IconButton.styleFrom( + backgroundColor: Colors.grey.shade100, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + const SizedBox(height: 32), + Center( + child: Container( + width: 80, + height: 80, + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF6C63FF), Color(0xFF48C6EF)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(24), + ), + child: const Icon(LucideIcons.shieldCheck, color: Colors.white, size: 36), + ), + ), + const SizedBox(height: 28), + const Center( + child: Text( + 'Reset Password', + style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold), + ), + ), + const SizedBox(height: 12), + Center( + child: Text( + 'We\'ve sent a 6-digit code to\n${widget.email}', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 15, color: Colors.grey.shade600, height: 1.5), + ), + ), + const SizedBox(height: 40), + + // OTP Field + TextField( + controller: otpController, + keyboardType: TextInputType.number, + textInputAction: TextInputAction.next, + maxLength: 6, + style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 20, letterSpacing: 8), + textAlign: TextAlign.center, + decoration: InputDecoration( + hintText: '000000', + hintStyle: TextStyle(color: Colors.grey.shade300, letterSpacing: 8), + counterText: '', + filled: true, + fillColor: Colors.grey.shade100, + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 18), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)), + ), + ), + const SizedBox(height: 20), + + // New Password + TextField( + controller: newPasswordController, + obscureText: _obscurePassword, + textInputAction: TextInputAction.next, + style: const TextStyle(fontWeight: FontWeight.w500), + decoration: InputDecoration( + hintText: 'New Password', + prefixIcon: Icon(LucideIcons.lock, color: Colors.grey.shade500), + suffixIcon: IconButton( + icon: Icon(_obscurePassword ? LucideIcons.eyeOff : LucideIcons.eye, color: Colors.grey.shade500), + onPressed: () => setState(() => _obscurePassword = !_obscurePassword), + ), + filled: true, + fillColor: Colors.grey.shade100, + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)), + ), + ), + const SizedBox(height: 16), + + // Confirm Password + TextField( + controller: confirmPasswordController, + obscureText: _obscureConfirm, + textInputAction: TextInputAction.done, + onSubmitted: (_) => _resetPassword(), + style: const TextStyle(fontWeight: FontWeight.w500), + decoration: InputDecoration( + hintText: 'Confirm Password', + prefixIcon: Icon(LucideIcons.lock, color: Colors.grey.shade500), + suffixIcon: IconButton( + icon: Icon(_obscureConfirm ? LucideIcons.eyeOff : LucideIcons.eye, color: Colors.grey.shade500), + onPressed: () => setState(() => _obscureConfirm = !_obscureConfirm), + ), + filled: true, + fillColor: Colors.grey.shade100, + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)), + ), + ), + + if (_errorMessage != null) ...[ + const SizedBox(height: 20), + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Colors.red.shade50, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.red.shade200), + ), + child: Row( + children: [ + Icon(LucideIcons.alertCircle, color: Colors.red.shade400, size: 18), + const SizedBox(width: 10), + Expanded(child: Text(_errorMessage!, style: TextStyle(color: Colors.red.shade700, fontSize: 13))), + ], + ), + ), + ], + const SizedBox(height: 32), + + SizedBox( + width: double.infinity, + height: 56, + child: ElevatedButton( + onPressed: _isLoading ? null : _resetPassword, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF6C63FF), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + elevation: 0, + ), + child: _isLoading + ? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) + : const Text('Reset Password', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/kifi-app/lib/features/auth/providers/auth_provider.dart b/kifi-app/lib/features/auth/providers/auth_provider.dart new file mode 100644 index 0000000..dedd0f3 --- /dev/null +++ b/kifi-app/lib/features/auth/providers/auth_provider.dart @@ -0,0 +1,81 @@ +import 'dart:async'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../data/auth_repository.dart'; +import '../../../../core/network/dio_client.dart'; + +final authRepositoryProvider = Provider((ref) => AuthRepository()); + +class AuthController extends AsyncNotifier { + late AuthRepository _repository; + + @override + FutureOr build() { + _repository = ref.watch(authRepositoryProvider); + } + + Future login(String email, String password) async { + state = const AsyncValue.loading(); + try { + final data = await _repository.login(email, password); + await DioClient().storage.write(key: 'jwt_token', value: data['token']); + state = const AsyncValue.data(null); + return true; + } catch (e, st) { + state = AsyncValue.error(e, st); + return false; + } + } + + Future signup(String email, String password) async { + state = const AsyncValue.loading(); + try { + await _repository.signup(email, password); + state = const AsyncValue.data(null); + return true; + } catch (e, st) { + state = AsyncValue.error(e, st); + return false; + } + } + + Future verifyOtp(String email, String otp) async { + state = const AsyncValue.loading(); + try { + final data = await _repository.verifyOtp(email, otp); + await DioClient().storage.write(key: 'jwt_token', value: data['token']); + state = const AsyncValue.data(null); + return true; + } catch (e, st) { + state = AsyncValue.error(e, st); + return false; + } + } + + Future forgotPassword(String email) async { + state = const AsyncValue.loading(); + try { + await _repository.forgotPassword(email); + state = const AsyncValue.data(null); + return true; + } catch (e, st) { + state = AsyncValue.error(e, st); + return false; + } + } + + Future resetPassword(String email, String otp, String newPassword) async { + state = const AsyncValue.loading(); + try { + await _repository.resetPassword(email, otp, newPassword); + state = const AsyncValue.data(null); + return true; + } catch (e, st) { + state = AsyncValue.error(e, st); + return false; + } + } +} + +final authControllerProvider = AsyncNotifierProvider(() { + return AuthController(); +}); diff --git a/kifi-app/lib/features/budget/presentation/budget_screen.dart b/kifi-app/lib/features/budget/presentation/budget_screen.dart new file mode 100644 index 0000000..7380816 --- /dev/null +++ b/kifi-app/lib/features/budget/presentation/budget_screen.dart @@ -0,0 +1,425 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import '../../transactions/providers/providers.dart'; +import '../../transactions/data/models.dart'; +import '../../../core/widgets/shimmer_loading.dart'; + +class BudgetScreen extends ConsumerStatefulWidget { + const BudgetScreen({super.key}); + + @override + ConsumerState createState() => _BudgetScreenState(); +} + +class _BudgetScreenState extends ConsumerState { + String _searchQuery = ''; + String? _filterNature; + bool _isSearching = false; + final TextEditingController _searchController = TextEditingController(); + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + Future _onRefresh() async { + ref.invalidate(budgetProvider); + ref.invalidate(walletProvider); + ref.invalidate(transactionProvider); + await Future.delayed(const Duration(milliseconds: 500)); + } + + void _showSetBudgetDialog(BuildContext context, Wallet wallet, Budget? existingBudget) { + final controller = TextEditingController(text: existingBudget?.monthlyLimit.toString() ?? ''); + bool isShared = existingBudget?.isShared ?? false; + + showDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (context, setState) { + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)), + elevation: 0, + backgroundColor: Colors.transparent, + child: GestureDetector( + onTap: () => FocusScope.of(context).unfocus(), + child: Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(28), + boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 20, offset: const Offset(0, 10))], + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Set Budget for ${wallet.name}', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + const SizedBox(height: 24), + TextField( + controller: controller, + keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: false), + textInputAction: TextInputAction.done, + style: const TextStyle(fontWeight: FontWeight.w500), + decoration: InputDecoration( + hintText: 'Monthly Limit', + prefixText: 'Rs. ', + prefixStyle: const TextStyle(color: Colors.black87, fontWeight: FontWeight.w500, fontSize: 16), + filled: true, + fillColor: Colors.grey.shade100, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)), + ), + ), + const SizedBox(height: 16), + SwitchListTile( + title: const Text('Share budget with wallet members', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500)), + value: isShared, + activeColor: const Color(0xFF6C63FF), + contentPadding: EdgeInsets.zero, + onChanged: (val) { + setState(() { + isShared = val; + }); + }, + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + style: TextButton.styleFrom(foregroundColor: Colors.grey.shade700, padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12))), + child: const Text('Cancel', style: TextStyle(fontWeight: FontWeight.bold)), + ), + const SizedBox(width: 12), + ElevatedButton( + onPressed: () async { + final limit = double.tryParse(controller.text); + if (limit != null && limit > 0) { + final newBudget = Budget( + id: existingBudget?.id ?? 0, + walletId: wallet.id, + monthlyLimit: limit, + isShared: isShared, + ); + await ref.read(budgetProvider.notifier).addOrUpdateBudget(newBudget); + if (mounted) Navigator.pop(ctx); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF6C63FF), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + elevation: 0, + ), + child: const Text('Save', style: TextStyle(fontWeight: FontWeight.bold)), + ) + ], + ), + ], + ), + ), + ), + ), + ); + } + ), + ); + } + + void _showFilterSheet() { + final natures = ['ALL', 'CASH', 'SAVINGS', 'INVESTMENTS', 'LOAN', 'LENDING', 'PAYABLES', 'INCOME', 'EXPENSE', 'RECEIVABLES']; + String? tempFilterNature = _filterNature; + + showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))), + builder: (ctx) => StatefulBuilder( + builder: (context, setSheetState) { + return Container( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: SafeArea( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Filter Budgets', style: Theme.of(context).textTheme.titleLarge), + IconButton(icon: const Icon(LucideIcons.x), onPressed: () => Navigator.pop(ctx)), + ], + ), + const SizedBox(height: 24), + + Text('Account Nature', style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: 8), + DropdownButtonFormField( + value: tempFilterNature ?? 'ALL', + style: const TextStyle(fontWeight: FontWeight.w500, color: Colors.black87, fontSize: 16), + decoration: InputDecoration( + filled: true, + fillColor: Colors.grey.shade100, + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)), + ), + items: natures.map((n) => DropdownMenuItem(value: n, child: Text(n))).toList(), + onChanged: (val) { + if (val != null) { + setSheetState(() { + tempFilterNature = val == 'ALL' ? null : val; + }); + } + }, + ), + const SizedBox(height: 32), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () { + setState(() => _filterNature = null); + Navigator.pop(ctx); + }, + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + side: BorderSide(color: Colors.grey.shade300), + ), + child: Text('Reset', style: TextStyle(color: Colors.grey.shade700, fontWeight: FontWeight.bold)), + ), + ), + const SizedBox(width: 16), + Expanded( + child: ElevatedButton( + onPressed: () { + setState(() => _filterNature = tempFilterNature); + Navigator.pop(ctx); + }, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + backgroundColor: const Color(0xFF6C63FF), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + elevation: 0, + ), + child: const Text('Apply Filter', style: TextStyle(fontWeight: FontWeight.bold)), + ), + ), + ], + ), + const SizedBox(height: 16), + ], + ), + ), + ), + ); + }, + ), + ); + } + + @override + Widget build(BuildContext context) { + final budgetsState = ref.watch(budgetProvider); + final walletsState = ref.watch(walletProvider); + final transState = ref.watch(transactionProvider); + + return Scaffold( + backgroundColor: Colors.transparent, + body: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 12), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _searchController, + onChanged: (val) => setState(() => _searchQuery = val), + decoration: InputDecoration( + hintText: 'Search budgets...', + prefixIcon: const Icon(LucideIcons.search), + suffixIcon: _searchController.text.isNotEmpty ? IconButton( + icon: const Icon(LucideIcons.x), + onPressed: () { + _searchController.clear(); + setState(() => _searchQuery = ''); + }, + ) : null, + filled: true, + fillColor: Colors.grey.shade100, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ), + ), + ), + const SizedBox(width: 8), + IconButton( + icon: Icon( + LucideIcons.filter, + color: _filterNature != null ? const Color(0xFF6C63FF) : null, + ), + onPressed: _showFilterSheet, + ), + ], + ), + ), + Expanded( + child: budgetsState.when( + loading: () => ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: 4, + itemBuilder: (context, index) => const ShimmerCard(), + ), + error: (err, stack) => Center(child: Text('Error: $err')), + data: (budgets) { + if (!walletsState.hasValue || !transState.hasValue) { + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: 4, + itemBuilder: (context, index) => const ShimmerCard(), + ); + } + + final wallets = walletsState.value!.where((w) { + final nature = (w.nature ?? 'CASH').trim().toUpperCase(); + final hasBudget = budgets.any((b) => b.walletId == w.id); + + final matchSearch = _searchQuery.isEmpty || w.name.toLowerCase().contains(_searchQuery.toLowerCase()); + final matchNature = _filterNature == null || nature == _filterNature; + + final matchBaseCondition = hasBudget || nature == 'EXPENSE' || nature == 'INVESTMENTS'; + + return matchBaseCondition && matchSearch && matchNature; + }).toList(); + + final transactions = transState.value!; + final now = DateTime.now(); + + // Calculate spent per wallet for the current month + final currentMonthTxs = transactions.where((t) => t.date.month == now.month && t.date.year == now.year); + final Map spentByWallet = {}; + + for (var t in currentMonthTxs) { + if (t.toWalletId != null) { + spentByWallet[t.toWalletId!] = (spentByWallet[t.toWalletId!] ?? 0) + t.amount; + } + } + + if (wallets.isEmpty) { + return const Center(child: Padding(padding: EdgeInsets.all(16), child: Text('No budgets or eligible accounts found.'))); + } + + return RefreshIndicator( + onRefresh: _onRefresh, + child: ListView.builder( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(16), + itemCount: wallets.length, + itemBuilder: (context, index) { + final wallet = wallets[index]; + final existingBudgetIndex = budgets.indexWhere((b) => b.walletId == wallet.id); + final budget = existingBudgetIndex >= 0 ? budgets[existingBudgetIndex] : null; + final spent = spentByWallet[wallet.id] ?? 0.0; + + if (budget == null) { + return Card( + margin: const EdgeInsets.only(bottom: 12), + child: ListTile( + leading: const CircleAvatar(child: Icon(LucideIcons.wallet)), + title: Text(wallet.name), + subtitle: Text('${wallet.nature} • No budget set'), + trailing: TextButton( + onPressed: () => _showSetBudgetDialog(context, wallet, null), + child: const Text('Set Budget'), + ), + ), + ); + } + + final limit = budget.monthlyLimit; + final double progress; + if (limit <= 0) { + progress = 1.0; + } else { + progress = (spent / limit).clamp(0.0, 1.0); + } + Color progressColor = Colors.green; + if (progress > 0.9) progressColor = Colors.red; + else if (progress > 0.7) progressColor = Colors.orange; + + return Card( + margin: const EdgeInsets.only(bottom: 16), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Row( + children: [ + const Icon(LucideIcons.wallet, size: 20, color: Colors.blue), + const SizedBox(width: 8), + Expanded(child: Text(wallet.name, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16), overflow: TextOverflow.ellipsis)), + ], + ), + ), + IconButton( + icon: const Icon(LucideIcons.edit3, size: 18), + onPressed: () => _showSetBudgetDialog(context, wallet, budget), + ) + ], + ), + const SizedBox(height: 12), + LinearProgressIndicator( + value: progress, + backgroundColor: Colors.grey.shade200, + valueColor: AlwaysStoppedAnimation(progressColor), + minHeight: 8, + borderRadius: BorderRadius.circular(4), + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Spent: Rs. ${spent.toStringAsFixed(2)}', style: TextStyle(color: Colors.grey.shade700)), + Text('Limit: Rs. ${limit.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.bold)), + ], + ), + if (progress >= 1.0) + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Text('Budget Exceeded!', style: TextStyle(color: Colors.red.shade700, fontSize: 12, fontWeight: FontWeight.bold)), + ), + ], + ), + ), + ); + }), + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/kifi-app/lib/features/dashboard/presentation/accounts_screen.dart b/kifi-app/lib/features/dashboard/presentation/accounts_screen.dart new file mode 100644 index 0000000..fe4ff9f --- /dev/null +++ b/kifi-app/lib/features/dashboard/presentation/accounts_screen.dart @@ -0,0 +1,881 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import 'package:intl/intl.dart'; +import '../../transactions/providers/providers.dart'; +import '../../transactions/data/models.dart'; +import '../../transactions/data/repository.dart'; +import 'wallet_ledger_screen.dart'; + +class AccountsScreen extends ConsumerStatefulWidget { + final String? initialFilterNature; + const AccountsScreen({super.key, this.initialFilterNature}); + + @override + ConsumerState createState() => _AccountsScreenState(); +} + +class _AccountsScreenState extends ConsumerState { + String _searchQuery = ''; + String? _filterNature; + bool _isSearching = false; + final TextEditingController _searchController = TextEditingController(); + + @override + void initState() { + super.initState(); + _filterNature = widget.initialFilterNature; + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + Future _onRefresh() async { + ref.invalidate(walletProvider); + ref.invalidate(invitationProvider); + await Future.delayed(const Duration(milliseconds: 500)); + } + + void _showCreateWalletDialog() { + final ctrl = TextEditingController(); + final amtCtrl = TextEditingController(); + DateTime openingDate = DateTime.now(); + String selectedNature = 'CASH'; + final natures = ['CASH', 'SAVINGS', 'INVESTMENTS', 'LOAN', 'LENDING', 'PAYABLES', 'INCOME', 'EXPENSE', 'RECEIVABLES']; + showDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (context, setStateDialog) { + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)), + elevation: 0, + backgroundColor: Colors.transparent, + child: GestureDetector( + onTap: () => FocusScope.of(context).unfocus(), + child: Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(28), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 20, + offset: const Offset(0, 10), + ), + ], + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: const Color(0xFF6C63FF).withValues(alpha: 0.1), + shape: BoxShape.circle, + ), + child: const Icon(LucideIcons.wallet, color: Color(0xFF6C63FF), size: 24), + ), + const SizedBox(width: 16), + const Text( + 'New Account', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + ], + ), + const SizedBox(height: 24), + + TextField( + controller: ctrl, + style: const TextStyle(fontWeight: FontWeight.w500), + decoration: InputDecoration( + hintText: 'Account Name (e.g. Household)', + filled: true, + fillColor: Colors.grey.shade100, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)), + ), + ), + const SizedBox(height: 16), + + DropdownButtonFormField( + value: selectedNature, + style: const TextStyle(fontWeight: FontWeight.w500, color: Colors.black87), + decoration: InputDecoration( + labelText: 'Account Nature', + filled: true, + fillColor: Colors.grey.shade100, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)), + ), + items: natures.map((n) => DropdownMenuItem(value: n, child: Text(n))).toList(), + onChanged: (val) { + if (val != null) setStateDialog(() => selectedNature = val); + }, + ), + const SizedBox(height: 16), + + TextField( + controller: amtCtrl, + keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: false), + textInputAction: TextInputAction.done, + style: const TextStyle(fontWeight: FontWeight.w500), + decoration: InputDecoration( + hintText: 'Opening Balance (Optional)', + prefixText: 'Rs. ', + prefixStyle: const TextStyle(color: Colors.black87, fontWeight: FontWeight.w500, fontSize: 16), + filled: true, + fillColor: Colors.grey.shade100, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)), + ), + ), + const SizedBox(height: 16), + + InkWell( + onTap: () async { + final picked = await showDatePicker( + context: context, + initialDate: openingDate, + firstDate: DateTime.now().subtract(const Duration(days: 365 * 10)), + lastDate: DateTime.now(), + ); + if (picked != null) { + setStateDialog(() => openingDate = picked); + } + }, + borderRadius: BorderRadius.circular(16), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(16), + ), + child: Row( + children: [ + const Icon(LucideIcons.calendar, color: Colors.grey), + const SizedBox(width: 12), + Text( + 'Date: ${DateFormat('MMM dd, yyyy').format(openingDate)}', + style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500), + ), + ], + ), + ), + ), + const SizedBox(height: 24), + + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + style: TextButton.styleFrom( + foregroundColor: Colors.grey.shade700, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + child: const Text('Cancel', style: TextStyle(fontWeight: FontWeight.bold)), + ), + const SizedBox(width: 12), + ElevatedButton( + onPressed: () async { + if (ctrl.text.isNotEmpty) { + final double amt = double.tryParse(amtCtrl.text) ?? 0.0; + final newWallet = await ref.read(walletProvider.notifier).createWallet( + name: ctrl.text, + nature: selectedNature, + initialBalance: 0.0, + ); + + if (amt > 0) { + final tx = Transaction( + id: 0, + type: 'INCOME', + amount: amt, + date: openingDate, + description: 'Opening Balance', + toWalletId: newWallet.id, + ); + await ref.read(transactionProvider.notifier).addTransaction(tx); + } + + if (context.mounted) Navigator.pop(ctx); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF6C63FF), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + elevation: 0, + ), + child: const Text('Create', style: TextStyle(fontWeight: FontWeight.bold)), + ) + ], + ), + ], + ), + ), + ), + ), + ); + }, + ), + ); + } + + void _showFilterSheet() { + final natures = ['ALL', 'CASH', 'SAVINGS', 'INVESTMENTS', 'LOAN', 'LENDING', 'PAYABLES', 'INCOME', 'EXPENSE', 'RECEIVABLES']; + String? tempFilterNature = _filterNature; + + showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))), + builder: (ctx) => StatefulBuilder( + builder: (context, setSheetState) { + return Container( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: SafeArea( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Filter Accounts', style: Theme.of(context).textTheme.titleLarge), + IconButton(icon: const Icon(LucideIcons.x), onPressed: () => Navigator.pop(ctx)), + ], + ), + const SizedBox(height: 24), + + Text('Account Nature', style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: 8), + DropdownButtonFormField( + value: tempFilterNature ?? 'ALL', + style: const TextStyle(fontWeight: FontWeight.w500, color: Colors.black87, fontSize: 16), + decoration: InputDecoration( + filled: true, + fillColor: Colors.grey.shade100, + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)), + ), + items: natures.map((n) => DropdownMenuItem(value: n, child: Text(n))).toList(), + onChanged: (val) { + if (val != null) { + setSheetState(() { + tempFilterNature = val == 'ALL' ? null : val; + }); + } + }, + ), + const SizedBox(height: 32), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () { + setState(() => _filterNature = null); + Navigator.pop(ctx); + }, + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + side: BorderSide(color: Colors.grey.shade300), + ), + child: Text('Reset', style: TextStyle(color: Colors.grey.shade700, fontWeight: FontWeight.bold)), + ), + ), + const SizedBox(width: 16), + Expanded( + child: ElevatedButton( + onPressed: () { + setState(() => _filterNature = tempFilterNature); + Navigator.pop(ctx); + }, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + backgroundColor: const Color(0xFF6C63FF), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + elevation: 0, + ), + child: const Text('Apply Filter', style: TextStyle(fontWeight: FontWeight.bold)), + ), + ), + ], + ), + const SizedBox(height: 16), + ], + ), + ), + ), + ); + }, + ), + ); + } + + @override + Widget build(BuildContext context) { + final walletsState = ref.watch(walletProvider); + + return Scaffold( + backgroundColor: Colors.transparent, + body: SafeArea( + bottom: false, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 12), + child: Row( + children: [ + if (Navigator.of(context).canPop()) + Padding( + padding: const EdgeInsets.only(right: 8.0), + child: IconButton( + icon: const Icon(LucideIcons.arrowLeft), + onPressed: () => Navigator.pop(context), + ), + ), + Expanded( + child: TextField( + controller: _searchController, + onChanged: (val) => setState(() => _searchQuery = val), + decoration: InputDecoration( + hintText: 'Search accounts...', + prefixIcon: const Icon(LucideIcons.search), + suffixIcon: _searchController.text.isNotEmpty ? IconButton( + icon: const Icon(LucideIcons.x), + onPressed: () { + _searchController.clear(); + setState(() => _searchQuery = ''); + }, + ) : null, + filled: true, + fillColor: Colors.grey.shade100, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ), + ), + ), + const SizedBox(width: 8), + IconButton( + icon: Icon( + LucideIcons.filter, + color: _filterNature != null ? const Color(0xFF6C63FF) : null, + ), + onPressed: _showFilterSheet, + ), + IconButton( + icon: const Icon(LucideIcons.plusCircle), + onPressed: _showCreateWalletDialog, + ), + ], + ), + ), + Expanded( + child: walletsState.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, st) => Center(child: Text('Error: $e')), + data: (allWallets) { + final wallets = allWallets.where((w) { + final matchSearch = _searchQuery.isEmpty || + w.name.toLowerCase().contains(_searchQuery.toLowerCase()); + final matchNature = _filterNature == null || w.nature == _filterNature; + return matchSearch && matchNature; + }).toList(); + + if (wallets.isEmpty) { + return const Center(child: Text('No accounts found.')); + } + + return RefreshIndicator( + onRefresh: _onRefresh, + child: ListView.builder( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(16), + itemCount: wallets.length, + itemBuilder: (context, index) { + final w = wallets[index]; + return Card( + margin: const EdgeInsets.only(bottom: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16), side: BorderSide(color: Colors.grey.shade200)), + elevation: 0, + child: Column( + children: [ + InkWell( + onTap: () { + Navigator.push(context, MaterialPageRoute(builder: (_) => WalletLedgerScreen(wallet: w))); + }, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + children: [ + CircleAvatar( + backgroundColor: Colors.blue.withValues(alpha: 0.1), + child: const Icon(LucideIcons.wallet, color: Colors.blue), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(w.name, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), + const SizedBox(height: 4), + Text('${w.nature ?? "CASH"} • Balance: Rs. ${w.balance}', style: TextStyle(color: Colors.grey.shade600, fontSize: 13)), + ], + ), + ), + ], + ), + ), + ), + Divider(height: 1, color: Colors.grey.shade200), + FittedBox( + fit: BoxFit.scaleDown, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + TextButton.icon( + icon: const Icon(LucideIcons.userPlus, size: 14), + label: const Text('Invite', style: TextStyle(fontSize: 12)), + style: TextButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 8)), + onPressed: () { + showDialog( + context: context, + builder: (ctx) => InviteDialogWidget(wallet: w), + ); + }, + ), + TextButton.icon( + icon: const Icon(LucideIcons.edit2, size: 14), + label: const Text('Edit', style: TextStyle(fontSize: 12)), + style: TextButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 8)), + onPressed: () { + final editCtrl = TextEditingController(text: w.name); + String editNature = w.nature ?? 'CASH'; + final natures = ['CASH', 'SAVINGS', 'EXPENSE', 'INVESTMENTS', 'LOAN', 'LENDING', 'PAYABLES', 'RECEIVABLES', 'INCOME']; + showDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (context, setStateDialog) => Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)), + elevation: 0, + backgroundColor: Colors.transparent, + child: Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(28)), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Edit Account', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + const SizedBox(height: 24), + TextField( + controller: editCtrl, + decoration: InputDecoration( + hintText: 'Account Name', + filled: true, + fillColor: Colors.grey.shade100, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none), + ), + ), + const SizedBox(height: 16), + DropdownButtonFormField( + value: editNature, + decoration: InputDecoration( + labelText: 'Account Nature', + filled: true, + fillColor: Colors.grey.shade100, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none), + ), + items: natures.map((n) => DropdownMenuItem(value: n, child: Text(n))).toList(), + onChanged: (val) { + if (val != null) setStateDialog(() => editNature = val); + }, + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Cancel', style: TextStyle(fontWeight: FontWeight.bold)), + ), + const SizedBox(width: 12), + ElevatedButton( + onPressed: () async { + if (editCtrl.text.isNotEmpty) { + try { + await ref.read(walletProvider.notifier).editWallet( + w.id, + name: editCtrl.text.trim(), + nature: editNature, + ); + if (context.mounted) { + Navigator.pop(ctx); + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Wallet updated'))); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to edit: $e'))); + } + } + } + }, + style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF6C63FF), foregroundColor: Colors.white), + child: const Text('Save', style: TextStyle(fontWeight: FontWeight.bold)), + ) + ], + ), + ], + ), + ), + ), + ), + ), + ); + }, + ), + TextButton.icon( + icon: const Icon(LucideIcons.trash2, size: 14, color: Colors.red), + label: const Text('Delete', style: TextStyle(color: Colors.red, fontSize: 12)), + style: TextButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 8)), + onPressed: () async { + final confirm = await showDialog( + context: context, + builder: (ctx) => Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)), + elevation: 0, + backgroundColor: Colors.transparent, + child: Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(28)), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Delete Account', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.red)), + const SizedBox(height: 16), + Text('Are you sure you want to delete ${w.name}? This action cannot be undone.', style: const TextStyle(fontSize: 16)), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancel', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.grey)), + ), + const SizedBox(width: 12), + ElevatedButton( + onPressed: () => Navigator.pop(ctx, true), + style: ElevatedButton.styleFrom(backgroundColor: Colors.red, foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))), + child: const Text('Delete', style: TextStyle(fontWeight: FontWeight.bold)), + ) + ], + ), + ], + ), + ), + ), + ); + + if (confirm == true) { + try { + await ref.read(walletProvider.notifier).deleteWallet(w.id); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Wallet deleted'))); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString().replaceAll('Exception: ', '')))); + } + } + } + }, + ), + TextButton.icon( + icon: const Icon(LucideIcons.list, size: 14), + label: const Text('Ledger', style: TextStyle(fontSize: 12)), + style: TextButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 8)), + onPressed: () { + Navigator.push(context, MaterialPageRoute(builder: (_) => WalletLedgerScreen(wallet: w))); + }, + ), + ], + ), + ), // Close FittedBox + ], + ), + ); + }, + ), + ); + }, + ), + ), + ], + ), + ), + ); + } +} + +class InviteDialogWidget extends ConsumerStatefulWidget { + final Wallet wallet; + const InviteDialogWidget({super.key, required this.wallet}); + + @override + ConsumerState createState() => _InviteDialogWidgetState(); +} + +class _InviteDialogWidgetState extends ConsumerState { + TextEditingController _autoCompleteCtrl = TextEditingController(); + bool _isInviting = false; + bool _isLoadingMembers = true; + List _members = []; + List _knownContacts = []; + + @override + void initState() { + super.initState(); + _fetchMembers(); + } + + @override + void dispose() { + _autoCompleteCtrl.dispose(); + super.dispose(); + } + + Future _fetchMembers() async { + try { + final members = await ApiRepository().getWalletMembers(widget.wallet.id); + + List contacts = []; + try { + contacts = await ApiRepository().getKnownContacts(); + } catch (e) { + debugPrint("Error fetching contacts: $e"); + } + + if (mounted) { + setState(() { + _members = members; + _knownContacts = contacts.where((c) => !members.any((m) => m.email == c)).toList(); + _isLoadingMembers = false; + }); + } + } catch (e) { + if (mounted) { + setState(() => _isLoadingMembers = false); + } + } + } + + Future _removeMember(WalletMember member) async { + final confirm = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Remove Member'), + content: Text('Are you sure you want to remove ${member.email} from this wallet?'), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancel')), + ElevatedButton( + onPressed: () => Navigator.pop(ctx, true), + style: ElevatedButton.styleFrom(backgroundColor: Colors.red, foregroundColor: Colors.white), + child: const Text('Remove'), + ), + ], + ), + ); + + if (confirm == true) { + try { + await ApiRepository().removeWalletMember(widget.wallet.id, member.userId); + await _fetchMembers(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Member removed.'))); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString().replaceAll('Exception: ', '')))); + } + } + } + } + + @override + Widget build(BuildContext context) { + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)), + elevation: 0, + backgroundColor: Colors.transparent, + child: GestureDetector( + onTap: () => FocusScope.of(context).unfocus(), + child: Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(28), + boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 20, offset: const Offset(0, 10))], + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Invite to ${widget.wallet.name}', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + const SizedBox(height: 24), + TextField( + controller: _autoCompleteCtrl, + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.done, + style: const TextStyle(fontWeight: FontWeight.w500), + onChanged: (val) { + setState(() {}); + }, + decoration: InputDecoration( + hintText: 'Enter Email Address to invite', + filled: true, + fillColor: Colors.grey.shade100, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)), + ), + ), + if (_autoCompleteCtrl?.text.trim().isNotEmpty == true && (_autoCompleteCtrl?.text.length ?? 0) >= 2) + Builder( + builder: (context) { + final query = _autoCompleteCtrl.text.trim().toLowerCase(); + final matches = _knownContacts.where((c) => c.toLowerCase().contains(query)).toList(); + if (matches.isEmpty || (matches.length == 1 && matches.first.toLowerCase() == query)) return const SizedBox.shrink(); + + return Container( + margin: const EdgeInsets.only(top: 8), + constraints: const BoxConstraints(maxHeight: 160), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade300), + boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.05), blurRadius: 10, offset: const Offset(0, 4))], + ), + child: ListView.separated( + shrinkWrap: true, + padding: EdgeInsets.zero, + itemCount: matches.length, + separatorBuilder: (_, __) => const Divider(height: 1, indent: 16, endIndent: 16), + itemBuilder: (ctx, index) { + final email = matches[index]; + return ListTile( + leading: const CircleAvatar(radius: 14, backgroundColor: Color(0xFF6C63FF), child: Icon(LucideIcons.user, size: 14, color: Colors.white)), + title: Text(email, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)), + onTap: () { + setState(() { + _autoCompleteCtrl.text = email; + _autoCompleteCtrl.selection = TextSelection.fromPosition(TextPosition(offset: email.length)); + }); + FocusScope.of(context).unfocus(); + }, + ); + }, + ), + ); + }, + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: _isInviting ? null : () => Navigator.pop(context), + style: TextButton.styleFrom(foregroundColor: Colors.grey.shade700, padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12))), + child: const Text('Cancel', style: TextStyle(fontWeight: FontWeight.bold)), + ), + const SizedBox(width: 12), + ElevatedButton( + onPressed: _isInviting ? null : () async { + final email = _autoCompleteCtrl.text.trim(); + if (email.isNotEmpty && email.contains('@')) { + setState(() => _isInviting = true); + try { + await ref.read(walletProvider.notifier).inviteUser(widget.wallet.id, email); + if (context.mounted) { + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('User invited!'))); + } + } catch (e) { + if (context.mounted) { + setState(() => _isInviting = false); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString().replaceAll('Exception: ', '')))); + } + } + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF6C63FF), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + elevation: 0, + ), + child: _isInviting + ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) + : const Text('Invite', style: TextStyle(fontWeight: FontWeight.bold)), + ) + ], + ), + const SizedBox(height: 24), + const Text('Existing Members', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + const SizedBox(height: 12), + _isLoadingMembers + ? const Center(child: CircularProgressIndicator()) + : _members.isEmpty + ? const Text('No members found.', style: TextStyle(color: Colors.grey)) + : ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: _members.length, + itemBuilder: (context, index) { + final member = _members[index]; + final isOwner = member.role == 'OWNER'; + return ListTile( + contentPadding: EdgeInsets.zero, + leading: CircleAvatar(backgroundColor: Colors.grey.shade200, child: const Icon(LucideIcons.user, color: Colors.grey)), + title: Text(member.email, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14)), + subtitle: Text(member.role, style: TextStyle(color: isOwner ? Colors.blue : Colors.grey, fontSize: 12)), + trailing: !isOwner + ? IconButton( + icon: const Icon(LucideIcons.userMinus, color: Colors.red, size: 20), + onPressed: () => _removeMember(member), + ) + : null, + ); + }, + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/kifi-app/lib/features/dashboard/presentation/dashboard_screen.dart b/kifi-app/lib/features/dashboard/presentation/dashboard_screen.dart new file mode 100644 index 0000000..991afbf --- /dev/null +++ b/kifi-app/lib/features/dashboard/presentation/dashboard_screen.dart @@ -0,0 +1,604 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import 'package:intl/intl.dart'; +import '../../transactions/presentation/add_transaction_screen.dart'; +import '../../transactions/presentation/all_transactions_screen.dart'; +import '../../transactions/providers/providers.dart'; +import '../../auth/presentation/profile_screen.dart'; +import '../../transactions/data/models.dart'; + +import '../../budget/presentation/budget_screen.dart'; + +import '../providers/insight_provider.dart'; +import 'widgets/budget_status_card.dart'; +import 'widgets/statistics_tab.dart'; +import '../../../core/widgets/shimmer_loading.dart'; +import '../../../core/theme/nature_colors.dart'; +import 'wallet_ledger_screen.dart'; +import 'maturity_dialog.dart'; +import 'accounts_screen.dart'; + +class DashboardScreen extends ConsumerStatefulWidget { + const DashboardScreen({super.key}); + + @override + ConsumerState createState() => _DashboardScreenState(); +} + +class _DashboardScreenState extends ConsumerState { + int _currentIndex = 0; + String _selectedFilter = 'All Time'; + DateTimeRange? _customDateRange; + Timer? _notificationTimer; + + final List _filters = ['Today', 'Last 3 Days', 'Week', 'Month', 'Year', 'All Time', 'Custom']; + + @override + void initState() { + super.initState(); + _notificationTimer = Timer.periodic(const Duration(minutes: 2), (_) { + ref.invalidate(invitationProvider); + }); + } + + @override + void dispose() { + _notificationTimer?.cancel(); + super.dispose(); + } + + Future _onRefresh() async { + ref.invalidate(transactionProvider); + ref.invalidate(walletProvider); + ref.invalidate(categoryProvider); + ref.invalidate(budgetProvider); + ref.invalidate(invitationProvider); + await Future.delayed(const Duration(milliseconds: 500)); + } + + DateTimeRange _getDateRange(List all) { + if (_selectedFilter == 'All Time') { + DateTime start = DateTime(2000); + if (all.isNotEmpty) { + start = all.map((t) => t.date).reduce((a, b) => a.isBefore(b) ? a : b); + } + return DateTimeRange(start: start, end: DateTime.now()); + } + + final now = DateTime.now(); + DateTime start; + DateTime end = now; + + switch (_selectedFilter) { + case 'Today': + start = DateTime(now.year, now.month, now.day); + break; + case 'Last 3 Days': + start = now.subtract(const Duration(days: 3)); + break; + case 'Week': + start = now.subtract(const Duration(days: 7)); + break; + case 'Month': + start = DateTime(now.year, now.month - 1, now.day); + break; + case 'Year': + start = DateTime(now.year - 1, now.month, now.day); + break; + case 'Custom': + if (_customDateRange != null) { + start = _customDateRange!.start; + end = _customDateRange!.end; + } else { + start = DateTime(2000); + if (all.isNotEmpty) { + start = all.map((t) => t.date).reduce((a, b) => a.isBefore(b) ? a : b); + } + } + break; + default: + start = DateTime(2000); + } + return DateTimeRange(start: start, end: end); + } + + List _filterTransactions(List all) { + if (_selectedFilter == 'All Time' || (_selectedFilter == 'Custom' && _customDateRange == null)) return all; + + final range = _getDateRange(all); + final start = range.start; + final end = range.end; + return all.where((t) { + final d = t.date; + return d.isAfter(start.subtract(const Duration(seconds: 1))) && + d.isBefore(end.add(const Duration(days: 1))); + }).toList(); + } + + Future _pickCustomDateRange() async { + final picked = await showDateRangePicker( + context: context, + firstDate: DateTime(2000), + lastDate: DateTime(2101), + ); + if (picked != null) { + setState(() { + _customDateRange = picked; + _selectedFilter = 'Custom'; + }); + } + } + + @override + Widget build(BuildContext context) { + final transState = ref.watch(transactionProvider); + final categoriesState = ref.watch(categoryProvider); + final insight = ref.watch(insightProvider); + final walletsState = ref.watch(walletProvider); + final allTransactions = transState.value ?? []; + + final range = _getDateRange(allTransactions); + final startDate = range.start; + final endDate = range.end; + + String title; + if (_currentIndex == 0) title = 'Dashboard'; + else if (_currentIndex == 1) title = 'Statistics'; + else if (_currentIndex == 2) title = 'My Accounts'; + else title = 'Budgets'; + + return Scaffold( + appBar: AppBar( + title: Text(title), + backgroundColor: Colors.transparent, + elevation: 0, + actions: [ + Consumer( + builder: (context, ref, child) { + final invitationsState = ref.watch(invitationProvider); + final pendingInvites = invitationsState.value?.where((inv) => inv.status == 'PENDING').toList() ?? []; + + return PopupMenuButton( + icon: Stack( + children: [ + const Icon(LucideIcons.bell), + if (pendingInvites.isNotEmpty) + Positioned( + right: 0, + top: 0, + child: Container( + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: Colors.red, + borderRadius: BorderRadius.circular(6), + ), + constraints: const BoxConstraints(minWidth: 12, minHeight: 12), + child: Text( + '${pendingInvites.length}', + style: const TextStyle(color: Colors.white, fontSize: 8, fontWeight: FontWeight.bold), + textAlign: TextAlign.center, + ), + ), + ) + ], + ), + offset: const Offset(0, 50), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + itemBuilder: (context) { + if (pendingInvites.isEmpty) { + return [ + const PopupMenuItem( + enabled: false, + child: Text('No new notifications'), + ) + ]; + } + return pendingInvites.map((inv) => PopupMenuItem( + enabled: false, + child: Container( + width: 250, + padding: const EdgeInsets.symmetric(vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('You have been invited to a wallet by User ${inv.inviterId}', style: const TextStyle(fontSize: 14)), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () { + Navigator.pop(context); + ref.read(invitationProvider.notifier).rejectInvitation(inv.id); + }, + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Reject'), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: () { + Navigator.pop(context); + ref.read(invitationProvider.notifier).acceptInvitation(inv.id); + }, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF6C63FF), + foregroundColor: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + child: const Text('Accept'), + ), + ], + ) + ], + ), + ), + )).toList(); + }, + ); + } + ), + IconButton( + icon: const Icon(LucideIcons.user), + onPressed: () { + Navigator.push(context, MaterialPageRoute(builder: (_) => const ProfileScreen())); + }, + ), + ], + ), + body: transState.when( + loading: () => ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: 4, + itemBuilder: (context, index) => const ShimmerCard(), + ), + error: (err, stack) => Center(child: Text('Error: $err')), + data: (allTransactions) { + final transactions = _filterTransactions(allTransactions); + + final totalIncome = transactions.where((t) => t.type == 'INCOME').fold(0.0, (s, t) => s + t.amount); + final totalExpense = transactions.where((t) => t.type == 'EXPENSE').fold(0.0, (s, t) => s + t.amount); + final totalInvestment = transactions.where((t) => t.type == 'INVESTMENT').fold(0.0, (s, t) => s + t.amount); + + double totalPayablesPeriod = 0.0; + double totalReceivablesPeriod = 0.0; + + final safeWallets = walletsState.hasValue ? walletsState.value! : []; + for (var t in transactions) { + if (t.toWalletId != null) { + final w = safeWallets.where((w) => w.id == t.toWalletId); + if (w.isNotEmpty) { + final nature = w.first.nature; + if (nature == 'PAYABLES' || nature == 'LOAN') { + totalPayablesPeriod += t.amount; + } else if (nature == 'RECEIVABLES' || nature == 'LENDING') { + totalReceivablesPeriod += t.amount; + } + } + } + } + + double cashBalance = 0; + double expenseBalance = 0; + double savingsBalance = 0; + double investmentsBalance = 0; + double payablesBalance = 0; + double receivablesBalance = 0; + + if (walletsState.hasValue) { + for (var w in walletsState.value!) { + final nature = w.nature ?? 'CASH'; + if (nature == 'CASH') { + cashBalance += w.balance; + } else if (nature == 'EXPENSE') { + expenseBalance += w.balance; + } else if (nature == 'SAVINGS' || nature == 'INCOME') { + savingsBalance += w.balance; + } else if (nature == 'INVESTMENTS') { + investmentsBalance += w.balance; + } else if (nature == 'LOAN' || nature == 'PAYABLES') { + payablesBalance += w.balance; + } else if (nature == 'LENDING') { + receivablesBalance += w.balance; + } + } + } + return IndexedStack( + index: _currentIndex, + children: [ + // ---------------- HOME TAB ---------------- + RefreshIndicator( + onRefresh: _onRefresh, + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (insight != null) ...[ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: insight.type == 'WARNING' ? Colors.orange.shade50 : (insight.type == 'SUCCESS' ? Colors.green.shade50 : Colors.blue.shade50), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: insight.type == 'WARNING' ? Colors.orange : (insight.type == 'SUCCESS' ? Colors.green : Colors.blue), width: 1), + ), + child: Row( + children: [ + Icon( + insight.type == 'WARNING' ? LucideIcons.alertTriangle : (insight.type == 'SUCCESS' ? LucideIcons.checkCircle : LucideIcons.info), + color: insight.type == 'WARNING' ? Colors.orange : (insight.type == 'SUCCESS' ? Colors.green : Colors.blue), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(insight.title, style: const TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 4), + Text(insight.message, style: const TextStyle(fontSize: 12)), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 24), + ], + const BudgetStatusCard(), + const SizedBox(height: 24), + + // Summary Cards Grid + GridView.count( + crossAxisCount: 2, + crossAxisSpacing: 16, + mainAxisSpacing: 16, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + childAspectRatio: 1.5, + children: [ + _buildSummaryCard(context, 'Balance', cashBalance, NatureColors.getColor('BALANCE'), LucideIcons.wallet, 'CASH'), + _buildSummaryCard(context, 'Expense', expenseBalance, NatureColors.getColor('EXPENSE'), LucideIcons.arrowUpRight, 'EXPENSE'), + _buildSummaryCard(context, 'Savings', savingsBalance, NatureColors.getColor('SAVINGS'), LucideIcons.piggyBank, 'SAVINGS'), + _buildSummaryCard(context, 'Investments', investmentsBalance, NatureColors.getColor('INVESTMENTS'), LucideIcons.trendingUp, 'INVESTMENTS'), + _buildSummaryCard(context, 'Payables', payablesBalance, NatureColors.getColor('PAYABLES'), LucideIcons.alertCircle, 'PAYABLES'), + _buildSummaryCard(context, 'Receivables', receivablesBalance, NatureColors.getColor('RECEIVABLES'), LucideIcons.arrowDownLeft, 'RECEIVABLES'), + ], + ), + const SizedBox(height: 32), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Recent Transactions', style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.bold)), + TextButton( + onPressed: () { + Navigator.push(context, MaterialPageRoute(builder: (_) => const AllTransactionsScreen())); + }, + child: const Text('See All'), + ), + ], + ), + const SizedBox(height: 8), + allTransactions.isEmpty + ? const Center(child: Padding(padding: EdgeInsets.all(16), child: Text('No transactions yet!'))) + : ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: allTransactions.length > 5 ? 5 : allTransactions.length, + itemBuilder: (context, index) { + final t = allTransactions[index]; + final isIncome = t.type == 'INCOME' || (t.type == 'TRANSFER' && t.fromWalletId == null); + final isInvestment = t.type == 'INVESTMENT'; + final isExpense = t.type == 'EXPENSE' || (t.type == 'TRANSFER' && t.toWalletId == null); + final isTransfer = t.type == 'TRANSFER' && t.fromWalletId != null && t.toWalletId != null; + + Color typeColor = Colors.grey; + IconData typeIcon = LucideIcons.arrowRightLeft; + + if (isIncome) { + typeColor = NatureColors.getColor('INCOME'); + typeIcon = LucideIcons.arrowDownCircle; + } else if (isExpense) { + typeColor = NatureColors.getColor('EXPENSE'); + typeIcon = LucideIcons.arrowUpCircle; + } else if (isInvestment) { + typeColor = NatureColors.getColor('INVESTMENTS'); + typeIcon = LucideIcons.trendingUp; + } else if (isTransfer) { + typeColor = NatureColors.getColor('TRANSFER'); + if (walletsState.hasValue) { + final toW = walletsState.value!.where((w) => w.id == t.toWalletId).firstOrNull; + final fromW = walletsState.value!.where((w) => w.id == t.fromWalletId).firstOrNull; + + if (toW != null && (toW.nature == 'PAYABLES' || toW.nature == 'LOAN')) { + typeColor = NatureColors.getColor('PAYABLES'); + typeIcon = LucideIcons.alertCircle; + } else if (toW != null && (toW.nature == 'RECEIVABLES' || toW.nature == 'LENDING')) { + typeColor = NatureColors.getColor('RECEIVABLES'); + typeIcon = LucideIcons.arrowDownLeft; + } else if (fromW != null && (fromW.nature == 'PAYABLES' || fromW.nature == 'LOAN')) { + typeColor = NatureColors.getColor('PAYABLES'); + typeIcon = LucideIcons.alertCircle; + } else if (fromW != null && (fromW.nature == 'RECEIVABLES' || fromW.nature == 'LENDING')) { + typeColor = NatureColors.getColor('RECEIVABLES'); + typeIcon = LucideIcons.arrowDownLeft; + } + } + } + + String categoryName = 'Unknown'; + if (categoriesState.hasValue) { + final match = categoriesState.value!.where((c) => c.id == t.categoryId); + if (match.isNotEmpty) categoryName = match.first.name; + } + + String accountName = 'Unknown'; + if (walletsState.hasValue) { + if (isExpense && t.toWalletId != null) { + final match = walletsState.value!.where((w) => w.id == t.toWalletId); + if (match.isNotEmpty) accountName = match.first.name; + } else if (isIncome && (t.toWalletId != null)) { + final match = walletsState.value!.where((w) => w.id == t.toWalletId); + if (match.isNotEmpty) accountName = match.first.name; + } else if (isTransfer && t.toWalletId != null) { + final match = walletsState.value!.where((w) => w.id == t.toWalletId); + if (match.isNotEmpty) accountName = 'To ${match.first.name}'; + } + } + + String fromName = ''; + if (t.fromWalletId != null && walletsState.hasValue) { + final match = walletsState.value!.where((w) => w.id == t.fromWalletId); + if (match.isNotEmpty) fromName = match.first.name; + } + + String toName = ''; + if (t.toWalletId != null && walletsState.hasValue) { + final match = walletsState.value!.where((w) => w.id == t.toWalletId); + if (match.isNotEmpty) toName = match.first.name; + } + + String subtitleText = ''; + if (categoryName == 'Unknown' && fromName.isNotEmpty && toName.isNotEmpty) { + subtitleText = '$fromName → $toName • ${DateFormat('MMM dd, yyyy').format(t.date)}'; + } else if (categoryName == 'Unknown' && toName.isNotEmpty) { + subtitleText = 'To $toName • ${DateFormat('MMM dd, yyyy').format(t.date)}'; + } else if (categoryName == 'Unknown' && fromName.isNotEmpty) { + subtitleText = 'From $fromName • ${DateFormat('MMM dd, yyyy').format(t.date)}'; + } else { + subtitleText = '$categoryName • ${DateFormat('MMM dd, yyyy').format(t.date)}${fromName.isNotEmpty ? ' • 💼 $fromName' : ''}'; + } + + return Card( + margin: const EdgeInsets.only(bottom: 12), + child: ListTile( + onTap: () { + Navigator.push(context, MaterialPageRoute(builder: (_) => AddTransactionScreen(transaction: t))); + }, + leading: CircleAvatar( + backgroundColor: typeColor.withOpacity(0.1), + child: Icon(typeIcon, color: typeColor), + ), + title: Text((t.description != null && t.description!.isNotEmpty) ? t.description! : accountName, style: const TextStyle(fontWeight: FontWeight.bold)), + subtitle: Text(subtitleText), + trailing: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '${isIncome ? '+' : (isTransfer ? '' : '-')}Rs. ${t.amount.toStringAsFixed(0)}', + style: TextStyle(fontWeight: FontWeight.bold, color: typeColor), + ), + if (t.investmentStatus == 'OPEN' && (typeColor == NatureColors.getColor('INVESTMENTS') || typeColor == NatureColors.getColor('RECEIVABLES'))) + GestureDetector( + onTap: () { + showDialog(context: context, builder: (_) => MaturityDialog(transaction: t)); + }, + child: const Padding( + padding: EdgeInsets.only(top: 4.0), + child: Text('Close', style: TextStyle(color: Colors.blue, fontWeight: FontWeight.bold, fontSize: 12)), + ), + ), + ], + ), + ), + ); + }, + ) + ], + ), + ), + ), + + // ---------------- STATS TAB ---------------- + StatisticsTab( + transactions: transactions, + wallets: safeWallets, + categories: categoriesState.value ?? [], + selectedFilter: _selectedFilter, + startDate: startDate, + endDate: endDate, + filters: _filters, + onFilterChanged: (val) { + setState(() => _selectedFilter = val); + }, + onPickCustomDateRange: _pickCustomDateRange, + onRefresh: _onRefresh, + ), + + // ---------------- WALLETS TAB ---------------- + const AccountsScreen(), + + // ---------------- BUDGETS TAB ---------------- + const BudgetScreen(), + ], + ); + }, + ), + bottomNavigationBar: BottomNavigationBar( + currentIndex: _currentIndex >= 2 ? _currentIndex + 1 : _currentIndex, + type: BottomNavigationBarType.fixed, + onTap: (index) { + if (index == 2) { + Navigator.push(context, MaterialPageRoute(builder: (_) => const AddTransactionScreen())); + } else { + setState(() { + _currentIndex = index > 2 ? index - 1 : index; + }); + } + }, + selectedItemColor: Theme.of(context).colorScheme.primary, + unselectedItemColor: Colors.grey, + showSelectedLabels: true, + showUnselectedLabels: true, + items: const [ + BottomNavigationBarItem(icon: Icon(LucideIcons.home), label: 'Home'), + BottomNavigationBarItem(icon: Icon(LucideIcons.pieChart), label: 'Stats'), + BottomNavigationBarItem(icon: Icon(LucideIcons.plusCircle), label: 'Add'), + BottomNavigationBarItem(icon: Icon(LucideIcons.wallet), label: 'Wallets'), + BottomNavigationBarItem(icon: Icon(LucideIcons.target), label: 'Budgets'), + ], + ), + ); + } + + Widget _buildSummaryCard(BuildContext context, String title, double amount, Color color, IconData icon, [String? nature]) { + return GestureDetector( + onTap: nature != null ? () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => AccountsScreen(initialFilterNature: nature), + ), + ); + } : null, + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: color.withOpacity(0.2)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Icon(icon, color: color, size: 16), + const SizedBox(width: 8), + Text(title, style: TextStyle(color: color, fontWeight: FontWeight.w600)), + ], + ), + Text( + 'Rs. ${amount.toStringAsFixed(0)}', + style: TextStyle(color: color, fontSize: 20, fontWeight: FontWeight.bold), + ), + ], + ), + ), + ); + } +} diff --git a/kifi-app/lib/features/dashboard/presentation/maturity_dialog.dart b/kifi-app/lib/features/dashboard/presentation/maturity_dialog.dart new file mode 100644 index 0000000..9e71c10 --- /dev/null +++ b/kifi-app/lib/features/dashboard/presentation/maturity_dialog.dart @@ -0,0 +1,313 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import 'package:intl/intl.dart'; +import '../../transactions/data/models.dart'; +import '../../transactions/providers/providers.dart'; + +class MaturityDialog extends ConsumerStatefulWidget { + final Transaction transaction; + + const MaturityDialog({super.key, required this.transaction}); + + @override + ConsumerState createState() => _MaturityDialogState(); +} + +class _MaturityDialogState extends ConsumerState { + final _amountController = TextEditingController(); + DateTime _maturityDate = DateTime.now(); + Wallet? _toWallet; + + @override + void initState() { + super.initState(); + _amountController.text = widget.transaction.amount.toStringAsFixed(2); + } + + void _pickDate() async { + final date = await showDatePicker( + context: context, + initialDate: _maturityDate, + firstDate: DateTime(2000), + lastDate: DateTime(2100), + builder: (context, child) { + return Theme( + data: Theme.of(context).copyWith( + colorScheme: const ColorScheme.light( + primary: Color(0xFF6C63FF), + onPrimary: Colors.white, + onSurface: Colors.black, + ), + ), + child: child!, + ); + }, + ); + if (date != null) { + setState(() => _maturityDate = date); + } + } + + @override + Widget build(BuildContext context) { + final walletsState = ref.watch(walletProvider); + List validWallets = []; + if (walletsState.hasValue) { + validWallets = walletsState.value!.where((w) => w.nature == 'CASH' || w.nature == 'SAVINGS').toList(); + if (_toWallet == null && validWallets.length == 1) { + _toWallet = validWallets.first; + } + } + + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)), + elevation: 0, + backgroundColor: Colors.transparent, + child: GestureDetector( + onTap: () => FocusScope.of(context).unfocus(), + child: Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(28), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 20, + offset: const Offset(0, 10), + ), + ], + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Header + Row( + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFF6C63FF).withValues(alpha: 0.1), + shape: BoxShape.circle, + ), + child: const Icon(LucideIcons.checkCircle, color: Color(0xFF6C63FF)), + ), + const SizedBox(width: 16), + const Expanded( + child: Text( + 'Close Investment', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Color(0xFF1E1E2D), + ), + ), + ), + ], + ), + const SizedBox(height: 24), + + // Original Investment Details Card + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.grey.shade50, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.grey.shade200), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Original Investment', + style: TextStyle(fontSize: 12, color: Colors.grey.shade600, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), + Text( + widget.transaction.description ?? 'Investment', + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + ), + const SizedBox(height: 4), + Text( + 'Rs. ${widget.transaction.amount.toStringAsFixed(2)}', + style: const TextStyle(color: Color(0xFF6C63FF), fontWeight: FontWeight.bold), + ), + ], + ), + ), + const SizedBox(height: 24), + + // Maturity Amount Field + Text( + 'Maturity Amount', + style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Colors.grey.shade700), + ), + const SizedBox(height: 8), + TextField( + controller: _amountController, + keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: false), + textInputAction: TextInputAction.done, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500), + decoration: InputDecoration( + prefixText: 'Rs. ', + prefixStyle: const TextStyle(color: Colors.black87, fontWeight: FontWeight.w500, fontSize: 16), + filled: true, + fillColor: Colors.grey.shade100, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2), + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + ), + ), + const SizedBox(height: 20), + + // Date Picker Field + Text( + 'Closure Date', + style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Colors.grey.shade700), + ), + const SizedBox(height: 8), + InkWell( + onTap: _pickDate, + borderRadius: BorderRadius.circular(16), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(16), + ), + child: Row( + children: [ + Icon(LucideIcons.calendar, color: Colors.grey.shade600, size: 20), + const SizedBox(width: 12), + Text( + DateFormat('dd MMM yyyy').format(_maturityDate), + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500), + ), + ], + ), + ), + ), + const SizedBox(height: 20), + + // To Account Dropdown + Text( + 'Deposit To (Savings/Cash)', + style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Colors.grey.shade700), + ), + const SizedBox(height: 8), + DropdownButtonFormField( + decoration: InputDecoration( + filled: true, + fillColor: Colors.grey.shade100, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2), + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + ), + icon: Icon(LucideIcons.chevronDown, color: Colors.grey.shade600), + value: _toWallet, + hint: const Text('Select an account'), + items: validWallets.map((w) { + return DropdownMenuItem( + value: w, + child: Text(w.name, style: const TextStyle(fontWeight: FontWeight.w500)), + ); + }).toList(), + onChanged: (val) { + setState(() => _toWallet = val); + }, + ), + const SizedBox(height: 32), + + // Action Buttons + Row( + children: [ + Expanded( + child: TextButton( + onPressed: () => Navigator.pop(context), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + ), + child: Text( + 'Cancel', + style: TextStyle(color: Colors.grey.shade600, fontWeight: FontWeight.bold, fontSize: 16), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton( + onPressed: () async { + final amount = double.tryParse(_amountController.text); + if (amount == null || amount <= 0) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Enter a valid amount'))); + return; + } + if (_toWallet == null) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Select a To Account'))); + return; + } + + try { + await ref.read(transactionProvider.notifier).closeInvestment( + widget.transaction.id, + amount, + _maturityDate, + _toWallet!.id + ); + if (mounted) { + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content: Text('Closure processed successfully!'), + backgroundColor: Colors.green, + behavior: SnackBarBehavior.floating, + )); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('Error: $e'), + backgroundColor: Colors.red, + behavior: SnackBarBehavior.floating, + )); + } + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF6C63FF), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + elevation: 0, + ), + child: const Text( + 'Confirm', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + ), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/kifi-app/lib/features/dashboard/presentation/wallet_ledger_screen.dart b/kifi-app/lib/features/dashboard/presentation/wallet_ledger_screen.dart new file mode 100644 index 0000000..4ef084d --- /dev/null +++ b/kifi-app/lib/features/dashboard/presentation/wallet_ledger_screen.dart @@ -0,0 +1,249 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; +import '../../transactions/providers/providers.dart'; +import '../../transactions/data/models.dart'; +import '../../../core/theme/nature_colors.dart'; + +class WalletLedgerScreen extends ConsumerStatefulWidget { + final Wallet wallet; + + const WalletLedgerScreen({super.key, required this.wallet}); + + @override + ConsumerState createState() => _WalletLedgerScreenState(); +} + +class _WalletLedgerScreenState extends ConsumerState { + String _selectedFilter = 'All Time'; + DateTimeRange? _customDateRange; + + void _showFilterSheet() { + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))), + builder: (ctx) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('Filter Ledger', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + const SizedBox(height: 16), + ListTile( + title: const Text('Today'), + onTap: () { + setState(() => _selectedFilter = 'Today'); + Navigator.pop(ctx); + }, + ), + ListTile( + title: const Text('Current Month'), + onTap: () { + setState(() => _selectedFilter = 'Current Month'); + Navigator.pop(ctx); + }, + ), + ListTile( + title: const Text('All Time'), + onTap: () { + setState(() => _selectedFilter = 'All Time'); + Navigator.pop(ctx); + }, + ), + ListTile( + title: const Text('Custom Range'), + onTap: () async { + Navigator.pop(ctx); + final range = await showDateRangePicker( + context: context, + firstDate: DateTime(2000), + lastDate: DateTime(2100), + ); + if (range != null) { + setState(() { + _selectedFilter = 'Custom Range'; + _customDateRange = range; + }); + } + }, + ), + ], + ), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + final transactionsState = ref.watch(transactionProvider); + final walletsState = ref.watch(walletProvider); + + return Scaffold( + appBar: AppBar( + title: Text('${widget.wallet.name} Ledger'), + actions: [ + IconButton( + icon: const Icon(Icons.filter_list), + onPressed: _showFilterSheet, + ), + ], + ), + body: transactionsState.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (err, stack) => Center(child: Text('Error: $err')), + data: (transactions) { + // 1. Filter all transactions related to this wallet + final walletTxs = transactions.where((t) => t.fromWalletId == widget.wallet.id || t.toWalletId == widget.wallet.id).toList(); + + // Sort chronologically (oldest first) to compute running balance + walletTxs.sort((a, b) => a.date.compareTo(b.date)); + + // 2. Determine date range + DateTime? startDate; + DateTime? endDate; + final now = DateTime.now(); + if (_selectedFilter == 'Today') { + startDate = DateTime(now.year, now.month, now.day); + endDate = DateTime(now.year, now.month, now.day, 23, 59, 59); + } else if (_selectedFilter == 'Current Month') { + startDate = DateTime(now.year, now.month, 1); + endDate = DateTime(now.year, now.month + 1, 0, 23, 59, 59); + } else if (_selectedFilter == 'Custom Range' && _customDateRange != null) { + startDate = _customDateRange!.start; + endDate = DateTime(_customDateRange!.end.year, _customDateRange!.end.month, _customDateRange!.end.day, 23, 59, 59); + } + + // 3. Calculate opening balance (sum of all transactions BEFORE start date) + double openingBalance = 0.0; + List visibleTxs = []; + + for (var t in walletTxs) { + double impact = 0; + if (t.toWalletId == widget.wallet.id) { + impact = t.amount; + } else if (t.fromWalletId == widget.wallet.id) { + impact = -t.amount; + } + + if (startDate != null && t.date.isBefore(startDate)) { + openingBalance += impact; + } else if (endDate != null && t.date.isAfter(endDate)) { + // skip + } else { + visibleTxs.add(t); + } + } + + // 4. Generate Ledger Rows + double runningBalance = openingBalance; + List rows = []; + + // Add Opening Balance Row if we have a date filter + if (startDate != null) { + rows.add(DataRow( + cells: [ + const DataCell(Text('-')), + DataCell(Text(DateFormat('dd MMM yyyy').format(startDate))), + DataCell(Text('Rs. ${openingBalance.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.bold))), + const DataCell(Text('-')), + const DataCell(Text('-')), + DataCell(Text('Rs. ${runningBalance.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.bold))), + ], + )); + } + + for (int i = 0; i < visibleTxs.length; i++) { + final t = visibleTxs[i]; + + bool isIncome = t.toWalletId == widget.wallet.id && t.fromWalletId == null; + bool isExpense = t.fromWalletId == widget.wallet.id && t.toWalletId == null; + bool isTransferIn = t.toWalletId == widget.wallet.id && t.fromWalletId != null; + bool isTransferOut = t.fromWalletId == widget.wallet.id && t.toWalletId != null; + + double amount = t.amount; + if (isExpense || isTransferOut) { + runningBalance -= amount; + } else { + runningBalance += amount; + } + + String fromName = '-'; + String toName = '-'; + + if (isIncome || isTransferIn) { + if (t.fromWalletId != null && walletsState.hasValue) { + fromName = walletsState.value!.where((w) => w.id == t.fromWalletId).firstOrNull?.name ?? 'Unknown'; + } else { + fromName = 'External'; + } + toName = widget.wallet.name; + } else if (isExpense || isTransferOut) { + fromName = widget.wallet.name; + if (t.toWalletId != null && walletsState.hasValue) { + toName = walletsState.value!.where((w) => w.id == t.toWalletId).firstOrNull?.name ?? 'Unknown'; + } else { + toName = 'External'; + } + } + + Color amountColor = (isExpense || isTransferOut) ? Colors.red : Colors.green.shade700; + String amountPrefix = (isExpense || isTransferOut) ? '-' : '+'; + + rows.add(DataRow( + cells: [ + DataCell(Text('${startDate != null ? i + 1 : i + 1}')), + DataCell(Text(DateFormat('dd MMM yyyy').format(t.date))), + DataCell(Text('$amountPrefix Rs. ${amount.toStringAsFixed(2)}', style: TextStyle(color: amountColor, fontWeight: FontWeight.w600))), + DataCell(Text(fromName)), + DataCell(Text(toName)), + DataCell(Text('Rs. ${runningBalance.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.bold))), + ], + )); + } + + if (rows.isEmpty) { + return const Center(child: Text('No transactions found for this period.')); + } + + return Column( + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Filter: $_selectedFilter', style: TextStyle(color: Colors.grey.shade600)), + Text('Closing Balance: Rs. ${runningBalance.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), + ], + ), + ), + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.vertical, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: DataTable( + headingRowColor: WidgetStateProperty.resolveWith((states) => Colors.grey.shade100), + columnSpacing: 24, + columns: const [ + DataColumn(label: Text('S.No', style: TextStyle(fontWeight: FontWeight.bold))), + DataColumn(label: Text('Date', style: TextStyle(fontWeight: FontWeight.bold))), + DataColumn(label: Text('Amount', style: TextStyle(fontWeight: FontWeight.bold))), + DataColumn(label: Text('From', style: TextStyle(fontWeight: FontWeight.bold))), + DataColumn(label: Text('To', style: TextStyle(fontWeight: FontWeight.bold))), + DataColumn(label: Text('Balance', style: TextStyle(fontWeight: FontWeight.bold))), + ], + rows: rows, + ), + ), + ), + ), + ], + ); + }, + ), + ); + } +} diff --git a/kifi-app/lib/features/dashboard/presentation/widgets/budget_status_card.dart b/kifi-app/lib/features/dashboard/presentation/widgets/budget_status_card.dart new file mode 100644 index 0000000..498f58f --- /dev/null +++ b/kifi-app/lib/features/dashboard/presentation/widgets/budget_status_card.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import '../../../transactions/providers/providers.dart'; + +class BudgetStatusCard extends ConsumerWidget { + const BudgetStatusCard({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final transState = ref.watch(transactionProvider); + final budgetState = ref.watch(budgetProvider); + + if (transState.isLoading || budgetState.isLoading) { + return const CircularProgressIndicator(); + } + + final transactions = transState.value ?? []; + final budgets = budgetState.value ?? []; + + if (budgets.isEmpty) { + return const SizedBox.shrink(); // Hide if no budgets + } + + final now = DateTime.now(); + final currentMonthTxs = transactions.where((t) => t.date.month == now.month && t.date.year == now.year); + + double totalSpent = 0; + for (var b in budgets) { + if (b.walletId != null) { + final spentForWallet = currentMonthTxs.where((t) => t.toWalletId == b.walletId).fold(0.0, (s, t) => s + t.amount); + totalSpent += spentForWallet; + } + } + + final totalLimit = budgets.fold(0.0, (s, b) => s + b.monthlyLimit); + final progress = totalLimit > 0 ? (totalSpent / totalLimit).clamp(0.0, 1.0) : 0.0; + + Color progressColor = Colors.green; + if (progress > 0.9) progressColor = Colors.red; + else if (progress > 0.7) progressColor = Colors.orange; + + return Card( + child: InkWell( + onTap: () { + // Could navigate to budget tab or push budget screen + }, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Row( + children: [ + Icon(LucideIcons.target, size: 18), + SizedBox(width: 8), + Text('Overall Budget Status', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), + ], + ), + const SizedBox(height: 12), + LinearProgressIndicator( + value: progress, + backgroundColor: Colors.grey.shade200, + color: progressColor, + minHeight: 12, + borderRadius: BorderRadius.circular(6), + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Spent: Rs. ${totalSpent.toStringAsFixed(0)}', style: TextStyle(color: Colors.grey.shade700)), + Text('Limit: Rs. ${totalLimit.toStringAsFixed(0)}', style: const TextStyle(fontWeight: FontWeight.bold)), + ], + ) + ], + ), + ), + ), + ); + } +} diff --git a/kifi-app/lib/features/dashboard/presentation/widgets/category_spending_chart.dart b/kifi-app/lib/features/dashboard/presentation/widgets/category_spending_chart.dart new file mode 100644 index 0000000..ca926fa --- /dev/null +++ b/kifi-app/lib/features/dashboard/presentation/widgets/category_spending_chart.dart @@ -0,0 +1,221 @@ +import 'package:flutter/material.dart'; +import 'package:fl_chart/fl_chart.dart'; +import '../../../transactions/data/models.dart'; +import '../../../../core/theme/nature_colors.dart'; + +class CategorySpendingChart extends StatefulWidget { + final List transactions; + final List categories; + final String selectedNature; + + const CategorySpendingChart({ + super.key, + required this.transactions, + required this.categories, + required this.selectedNature, + }); + + @override + State createState() => _CategorySpendingChartState(); +} + +class _CategorySpendingChartState extends State { + int touchedIndex = -1; + + @override + Widget build(BuildContext context) { + final Map categoryTotals = {}; + for (var t in widget.transactions) { + bool matches = false; + if (widget.selectedNature == 'INCOME') matches = t.type == 'INCOME' || (t.type == 'TRANSFER' && t.fromWalletId == null); + else if (widget.selectedNature == 'EXPENSE') matches = t.type == 'EXPENSE' || (t.type == 'TRANSFER' && t.toWalletId == null); + else if (widget.selectedNature == 'INVESTMENTS') matches = t.type == 'INVESTMENT'; + + if (matches) { + final catId = t.categoryId ?? -1; + categoryTotals[catId] = (categoryTotals[catId] ?? 0) + t.amount; + } + } + + if (categoryTotals.isEmpty) { + return const SizedBox(); + } + + final List> sortedTotals = categoryTotals.entries.toList() + ..sort((a, b) => b.value.compareTo(a.value)); + + final List colors = NatureColors.getPalette(widget.selectedNature); + + double totalAmount = categoryTotals.values.fold(0.0, (s, e) => s + e); + + List sections = []; + for (int i = 0; i < sortedTotals.length; i++) { + final isTouched = i == touchedIndex; + final radius = isTouched ? 45.0 : 35.0; + final fontSize = isTouched ? 16.0 : 12.0; + + final amount = sortedTotals[i].value; + final percentage = (amount / totalAmount * 100).toStringAsFixed(1); + + sections.add( + PieChartSectionData( + showTitle: false, + color: colors[i % colors.length], + value: amount, + title: '$percentage%', + radius: radius, + titleStyle: TextStyle( + fontSize: fontSize, + fontWeight: FontWeight.bold, + color: Colors.white, + shadows: const [Shadow(color: Colors.black26, blurRadius: 2)], + ), + badgeWidget: isTouched + ? Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 4)], + ), + child: Icon(Icons.touch_app, size: 16, color: colors[i % colors.length]), + ) + : null, + badgePositionPercentageOffset: 1.1, + ), + ); + } + + return Card( + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(32), side: BorderSide(color: Colors.grey.shade200)), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(32), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Colors.white, Colors.grey.shade50], + ), + ), + padding: const EdgeInsets.all(28.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Category Breakdown', style: TextStyle(fontSize: 22, fontWeight: FontWeight.w800, letterSpacing: -0.5)), + const SizedBox(height: 32), + + SizedBox( + height: 220, + child: Stack( + alignment: Alignment.center, + children: [ + PieChart( + PieChartData( + pieTouchData: PieTouchData( + touchCallback: (FlTouchEvent event, pieTouchResponse) { + setState(() { + if (!event.isInterestedForInteractions || pieTouchResponse == null || pieTouchResponse.touchedSection == null) { + touchedIndex = -1; + return; + } + touchedIndex = pieTouchResponse.touchedSection!.touchedSectionIndex; + }); + }, + ), + borderData: FlBorderData(show: false), + sectionsSpace: 4, + centerSpaceRadius: 65, + sections: sections, + ), + ), + // Center Text + Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('Total', style: TextStyle(fontSize: 14, color: Colors.grey.shade500, fontWeight: FontWeight.w600)), + Text('Rs. ${totalAmount.toStringAsFixed(0)}', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w900)), + ], + ), + ], + ), + ), + + const SizedBox(height: 40), + const Text('Details', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: Colors.black87)), + const SizedBox(height: 16), + + // Modern List for Legends + ...List.generate(sortedTotals.length, (i) { + String categoryName = 'Uncategorized'; + if (sortedTotals[i].key != -1) { + final match = widget.categories.where((c) => c.id == sortedTotals[i].key); + if (match.isNotEmpty) categoryName = match.first.name; + } + + final amount = sortedTotals[i].value; + final percentage = (amount / totalAmount * 100).toStringAsFixed(1); + final isTouched = i == touchedIndex; + + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20), + decoration: BoxDecoration( + color: isTouched ? colors[i % colors.length].withValues(alpha: 0.05) : Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: isTouched ? colors[i % colors.length].withValues(alpha: 0.3) : Colors.grey.shade100, + width: isTouched ? 1.5 : 1.0, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: isTouched ? 0.05 : 0.02), + blurRadius: isTouched ? 12 : 6, + offset: const Offset(0, 4), + ) + ], + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: colors[i % colors.length].withValues(alpha: 0.15), + shape: BoxShape.circle, + ), + child: Icon(Icons.category, color: colors[i % colors.length], size: 20), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(categoryName, style: TextStyle(fontWeight: FontWeight.w700, fontSize: 15, color: Colors.grey.shade800)), + const SizedBox(height: 4), + Text('Rs. ${amount.toStringAsFixed(0)}', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 13, color: Colors.grey.shade500)), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: colors[i % colors.length].withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Text('$percentage%', style: TextStyle(fontWeight: FontWeight.w800, fontSize: 13, color: colors[i % colors.length])), + ), + ], + ), + ); + }), + ], + ), + ), + ); + } +} diff --git a/kifi-app/lib/features/dashboard/presentation/widgets/day_wise_spending_chart.dart b/kifi-app/lib/features/dashboard/presentation/widgets/day_wise_spending_chart.dart new file mode 100644 index 0000000..ffb7440 --- /dev/null +++ b/kifi-app/lib/features/dashboard/presentation/widgets/day_wise_spending_chart.dart @@ -0,0 +1,162 @@ +import 'package:flutter/material.dart'; +import 'package:fl_chart/fl_chart.dart'; +import '../../../transactions/data/models.dart'; +import '../../../../core/theme/nature_colors.dart'; + +class DayWiseSpendingChart extends StatelessWidget { + final List transactions; + final List wallets; + final String selectedNature; + final DateTime startDate; + final DateTime endDate; + + const DayWiseSpendingChart({ + super.key, + required this.transactions, + required this.wallets, + required this.selectedNature, + required this.startDate, + required this.endDate, + }); + + @override + Widget build(BuildContext context) { + // 1. Determine date range length + final int daysCount = endDate.difference(startDate).inDays + 1; + final int displayDays = daysCount > 0 && daysCount < 1000 ? daysCount : 30; // Guard against 'All Time' returning thousands + + final days = List.generate(displayDays, (i) => startDate.add(Duration(days: i))); + + // 2. Initialize daily totals + final Map dailyTotals = {}; + for (var d in days) { + dailyTotals["${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}"] = 0.0; + } + + // 3. Populate totals + for (var t in transactions) { + final tDate = "${t.date.year}-${t.date.month.toString().padLeft(2, '0')}-${t.date.day.toString().padLeft(2, '0')}"; + if (dailyTotals.containsKey(tDate)) { + bool matches = false; + if (selectedNature == 'INCOME') matches = t.type == 'INCOME' || (t.type == 'TRANSFER' && t.fromWalletId == null); + else if (selectedNature == 'EXPENSE') matches = t.type == 'EXPENSE' || (t.type == 'TRANSFER' && t.toWalletId == null); + else if (selectedNature == 'INVESTMENTS') matches = t.type == 'INVESTMENT'; + else if (selectedNature == 'PAYABLES' || selectedNature == 'RECEIVABLES') { + if (t.type == 'TRANSFER' && t.toWalletId != null) { + final w = wallets.where((w) => w.id == t.toWalletId); + if (w.isNotEmpty) { + final nature = w.first.nature; + if (selectedNature == 'PAYABLES' && (nature == 'PAYABLES' || nature == 'LOAN')) matches = true; + if (selectedNature == 'RECEIVABLES' && (nature == 'RECEIVABLES' || nature == 'LENDING')) matches = true; + } + } + } + + if (matches) { + dailyTotals[tDate] = dailyTotals[tDate]! + t.amount; + } + } + } + + double maxY = 0.0; + for (var total in dailyTotals.values) { + if (total > maxY) maxY = total; + } + if (maxY == 0) maxY = 100; + + Color barColor = NatureColors.getColor(selectedNature); + + // Make chart horizontally scrollable if there are many days + final screenWidth = MediaQuery.of(context).size.width; + double chartWidth = (screenWidth / 7) * displayDays; + if (chartWidth < screenWidth - 80) { // minimum width + chartWidth = screenWidth - 80; + } + + return Card( + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24), side: BorderSide(color: Colors.grey.shade200)), + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Spending Trends', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + const SizedBox(height: 32), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SizedBox( + height: 200, + width: chartWidth, + child: BarChart( + BarChartData( + alignment: BarChartAlignment.spaceAround, + maxY: maxY * 1.2, + barTouchData: BarTouchData(enabled: false), + titlesData: FlTitlesData( + show: true, + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + getTitlesWidget: (value, meta) { + final index = value.toInt(); + if (index < 0 || index >= days.length) return const SizedBox(); + final date = days[index]; + final text = "${date.day} ${_monthStr(date.month)}"; + + // To prevent overcrowding on large ranges + if (displayDays > 14 && index % 2 != 0) return const SizedBox(); + if (displayDays > 30 && index % 5 != 0) return const SizedBox(); + + return Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Text(text, style: const TextStyle(color: Colors.grey, fontSize: 10, fontWeight: FontWeight.bold)), + ); + }, + reservedSize: 28, + ), + ), + leftTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)), + topTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)), + rightTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)), + ), + gridData: FlGridData( + show: true, + drawVerticalLine: false, + horizontalInterval: maxY / 4 == 0 ? 1 : maxY / 4, + getDrawingHorizontalLine: (value) => FlLine(color: Colors.grey.shade200, strokeWidth: 1, dashArray: [4, 4]), + ), + borderData: FlBorderData(show: false), + barGroups: dailyTotals.entries.toList().asMap().entries.map((entry) { + return BarChartGroupData( + x: entry.key, + barRods: [ + BarChartRodData( + toY: entry.value.value, + color: barColor, + width: 16, + borderRadius: BorderRadius.circular(8), + backDrawRodData: BackgroundBarChartRodData( + show: true, + toY: maxY * 1.2, + color: Colors.grey.shade100, + ), + ), + ], + ); + }).toList(), + ), + ), + ), + ), + ], + ), + ), + ); + } + + String _monthStr(int m) { + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + return months[m - 1]; + } +} diff --git a/kifi-app/lib/features/dashboard/presentation/widgets/statistics_tab.dart b/kifi-app/lib/features/dashboard/presentation/widgets/statistics_tab.dart new file mode 100644 index 0000000..ab80e85 --- /dev/null +++ b/kifi-app/lib/features/dashboard/presentation/widgets/statistics_tab.dart @@ -0,0 +1,224 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import '../../../transactions/data/models.dart'; +import '../../../../core/theme/nature_colors.dart'; +import 'day_wise_spending_chart.dart'; +import 'category_spending_chart.dart'; + +class StatisticsTab extends StatefulWidget { + final List transactions; + final List wallets; + final List categories; + final String selectedFilter; + final DateTime startDate; + final DateTime endDate; + final VoidCallback onPickCustomDateRange; + final Function(String) onFilterChanged; + final Future Function() onRefresh; + final List filters; + + const StatisticsTab({ + super.key, + required this.transactions, + required this.wallets, + required this.categories, + required this.selectedFilter, + required this.startDate, + required this.endDate, + required this.onPickCustomDateRange, + required this.onFilterChanged, + required this.onRefresh, + required this.filters, + }); + + @override + State createState() => _StatisticsTabState(); +} + +class _StatisticsTabState extends State { + String _selectedNature = 'EXPENSE'; + + Widget _buildTotalCard(String title, double amount, Color color, IconData icon) { + return Card( + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16), side: BorderSide(color: Colors.grey.shade200)), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Row( + children: [ + CircleAvatar(backgroundColor: color.withOpacity(0.1), radius: 16, child: Icon(icon, color: color, size: 16)), + const SizedBox(width: 8), + Expanded(child: Text(title, style: const TextStyle(fontSize: 12, color: Colors.grey, fontWeight: FontWeight.bold), maxLines: 1, overflow: TextOverflow.ellipsis)), + ], + ), + const SizedBox(height: 12), + Text('Rs. ${amount.toStringAsFixed(0)}', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: color)), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + // Compute totals + final totalIncome = widget.transactions.where((t) => t.type == 'INCOME' || (t.type == 'TRANSFER' && t.fromWalletId == null)).fold(0.0, (s, t) => s + t.amount); + final totalExpense = widget.transactions.where((t) => t.type == 'EXPENSE' || (t.type == 'TRANSFER' && t.toWalletId == null)).fold(0.0, (s, t) => s + t.amount); + final totalInvestment = widget.transactions.where((t) => t.type == 'INVESTMENT').fold(0.0, (s, t) => s + t.amount); + + double totalPayablesPeriod = 0.0; + double totalReceivablesPeriod = 0.0; + + for (var t in widget.transactions) { + if (t.toWalletId != null) { + final w = widget.wallets.where((w) => w.id == t.toWalletId); + if (w.isNotEmpty) { + final nature = w.first.nature; + if (nature == 'PAYABLES' || nature == 'LOAN') { + totalPayablesPeriod += t.amount; + } else if (nature == 'RECEIVABLES' || nature == 'LENDING') { + totalReceivablesPeriod += t.amount; + } + } + } + } + + Color barColor = NatureColors.getColor(_selectedNature); + + return RefreshIndicator( + onRefresh: widget.onRefresh, + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: widget.filters.map((f) { + final isSelected = widget.selectedFilter == f; + return Padding( + padding: const EdgeInsets.only(right: 8), + child: ChoiceChip( + label: Text(f), + selected: isSelected, + onSelected: (val) { + if (val) { + if (f == 'Custom') { + widget.onPickCustomDateRange(); + } else { + widget.onFilterChanged(f); + } + } + }, + ), + ); + }).toList(), + ), + ), + if (widget.selectedFilter == 'Custom') + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Text( + '${widget.startDate.day} ${widget.startDate.month} - ${widget.endDate.day} ${widget.endDate.month}', + style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.grey), + ), + ), + + Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Analytics', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 0), + decoration: BoxDecoration( + color: barColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(20), + ), + child: DropdownButton( + value: _selectedNature, + underline: const SizedBox(), + icon: Icon(Icons.arrow_drop_down, color: barColor), + style: TextStyle(color: barColor, fontWeight: FontWeight.bold), + items: const [ + DropdownMenuItem(value: 'EXPENSE', child: Text('Expense')), + DropdownMenuItem(value: 'INCOME', child: Text('Income')), + DropdownMenuItem(value: 'PAYABLES', child: Text('Payables')), + DropdownMenuItem(value: 'RECEIVABLES', child: Text('Receivables')), + DropdownMenuItem(value: 'INVESTMENTS', child: Text('Investments')), + ], + onChanged: (val) { + if (val != null) { + setState(() => _selectedNature = val); + } + }, + ), + ), + ], + ), + const SizedBox(height: 24), + + if (widget.transactions.isEmpty) + Card( + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24), side: BorderSide(color: Colors.grey.shade200)), + child: const Padding( + padding: EdgeInsets.all(32.0), + child: Center(child: Text('No transactions in this period.', style: TextStyle(color: Colors.grey))), + ), + ) + else ...[ + DayWiseSpendingChart( + transactions: widget.transactions, + wallets: widget.wallets, + selectedNature: _selectedNature, + startDate: widget.startDate, + endDate: widget.endDate, + ), + const SizedBox(height: 16), + CategorySpendingChart( + transactions: widget.transactions, + categories: widget.categories, + selectedNature: _selectedNature, + ), + ], + + const SizedBox(height: 32), + const Text('Summary Totals', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + const SizedBox(height: 16), + + GridView.count( + crossAxisCount: 2, + crossAxisSpacing: 12, + mainAxisSpacing: 12, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + childAspectRatio: 1.5, + children: [ + _buildTotalCard('Income', totalIncome, NatureColors.getColor('INCOME'), LucideIcons.arrowDown), + _buildTotalCard('Expense', totalExpense, NatureColors.getColor('EXPENSE'), LucideIcons.arrowUp), + _buildTotalCard('Investment', totalInvestment, NatureColors.getColor('INVESTMENTS'), LucideIcons.trendingUp), + _buildTotalCard('Payables', totalPayablesPeriod, NatureColors.getColor('PAYABLES'), LucideIcons.alertCircle), + _buildTotalCard('Receivables', totalReceivablesPeriod, NatureColors.getColor('RECEIVABLES'), LucideIcons.arrowDownLeft), + ], + ), + const SizedBox(height: 32), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/kifi-app/lib/features/dashboard/providers/insight_provider.dart b/kifi-app/lib/features/dashboard/providers/insight_provider.dart new file mode 100644 index 0000000..06fbda5 --- /dev/null +++ b/kifi-app/lib/features/dashboard/providers/insight_provider.dart @@ -0,0 +1,100 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../transactions/providers/providers.dart'; +import '../../transactions/data/models.dart'; + +class Insight { + final String title; + final String message; + final String type; // 'WARNING', 'SUCCESS', 'INFO' + + Insight(this.title, this.message, this.type); +} + +final insightProvider = Provider((ref) { + final transState = ref.watch(transactionProvider); + final budgetsState = ref.watch(budgetProvider); + final categoriesState = ref.watch(categoryProvider); + + if (!transState.hasValue || !budgetsState.hasValue || !categoriesState.hasValue) { + return null; + } + + final transactions = transState.value!; + final budgets = budgetsState.value!; + final categories = categoriesState.value!; + final now = DateTime.now(); + + final currentMonthTxs = transactions.where((t) => t.date.month == now.month && t.date.year == now.year).toList(); + + final totalIncome = currentMonthTxs.where((t) => t.type == 'INCOME').fold(0.0, (s, t) => s + t.amount); + final totalExpense = currentMonthTxs.where((t) => t.type == 'EXPENSE').fold(0.0, (s, t) => s + t.amount); + + // Rule 1: Expenses > Income + if (totalExpense > totalIncome && totalIncome > 0) { + return Insight( + 'Spending Alert', + 'You have spent more than you earned this month! (Rs. ${totalExpense.toStringAsFixed(0)} vs Rs. ${totalIncome.toStringAsFixed(0)})', + 'WARNING' + ); + } + + // Rule 2: Over Budget Categories + for (var budget in budgets) { + final spent = currentMonthTxs + .where((t) => t.type == 'EXPENSE' && t.categoryId == budget.categoryId) + .fold(0.0, (s, t) => s + t.amount); + + if (spent > budget.monthlyLimit) { + final categoryName = categories.firstWhere((c) => c.id == budget.categoryId, orElse: () => Category(id: 0, name: 'Unknown')).name; + return Insight( + 'Budget Exceeded', + 'You have exceeded your $categoryName budget by Rs. ${(spent - budget.monthlyLimit).toStringAsFixed(0)}.', + 'WARNING' + ); + } else if (spent > budget.monthlyLimit * 0.9) { + final categoryName = categories.firstWhere((c) => c.id == budget.categoryId, orElse: () => Category(id: 0, name: 'Unknown')).name; + return Insight( + 'Nearing Budget Limit', + 'Careful! You have used ${(spent / budget.monthlyLimit * 100).toStringAsFixed(0)}% of your $categoryName budget.', + 'WARNING' + ); + } + } + + // Rule 3: Highest spending category warning if > 40% of total + if (totalExpense > 0) { + final Map spentByCategory = {}; + for (var t in currentMonthTxs.where((t) => t.type == 'EXPENSE')) { + if (t.categoryId != null) { + spentByCategory[t.categoryId!] = (spentByCategory[t.categoryId!] ?? 0) + t.amount; + } + } + + if (spentByCategory.isNotEmpty) { + final maxEntry = spentByCategory.entries.reduce((a, b) => a.value > b.value ? a : b); + if (maxEntry.value > totalExpense * 0.4) { + final categoryName = categories.firstWhere((c) => c.id == maxEntry.key, orElse: () => Category(id: 0, name: 'Unknown')).name; + return Insight( + 'High Concentration', + '${(maxEntry.value / totalExpense * 100).toStringAsFixed(0)}% of your expenses this month went to $categoryName.', + 'INFO' + ); + } + } + } + + // Default Positive Insight + if (totalIncome > 0 && totalExpense < totalIncome * 0.5) { + return Insight( + 'Great Job!', + 'You have saved over 50% of your income this month. Keep it up!', + 'SUCCESS' + ); + } + + return Insight( + 'On Track', + 'Your finances are looking stable this month.', + 'SUCCESS' + ); +}); diff --git a/kifi-app/lib/features/onboarding/presentation/onboarding_screen.dart b/kifi-app/lib/features/onboarding/presentation/onboarding_screen.dart new file mode 100644 index 0000000..9828a5c --- /dev/null +++ b/kifi-app/lib/features/onboarding/presentation/onboarding_screen.dart @@ -0,0 +1,139 @@ +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import '../../dashboard/presentation/dashboard_screen.dart'; + +class OnboardingScreen extends StatefulWidget { + const OnboardingScreen({super.key}); + + @override + State createState() => _OnboardingScreenState(); +} + +class _OnboardingScreenState extends State { + final PageController _pageController = PageController(); + int _currentPage = 0; + + final List> _pages = [ + { + 'title': 'Welcome to Kifi', + 'description': 'A beautiful, intelligent way to track your personal finances.', + 'icon': LucideIcons.wallet, + }, + { + 'title': 'Double-Entry, Simplified', + 'description': 'We track money moving from one place to another. Every expense comes from a Wallet (like Cash) and goes to a Category (like Food).', + 'icon': LucideIcons.arrowRightLeft, + }, + { + 'title': 'Intelligent Automation', + 'description': 'Set up recurring transactions and let Kifi handle your monthly subscriptions and salary deposits automatically.', + 'icon': LucideIcons.bot, + }, + { + 'title': 'Powerful Insights', + 'description': 'View day-by-day spending trends and strict monthly budgets to take control of your financial future.', + 'icon': LucideIcons.barChart2, + }, + ]; + + void _completeOnboarding() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool('has_seen_onboarding', true); + if (mounted) { + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (context) => const DashboardScreen()), + ); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SafeArea( + child: Column( + children: [ + Expanded( + child: PageView.builder( + controller: _pageController, + onPageChanged: (index) { + setState(() => _currentPage = index); + }, + itemCount: _pages.length, + itemBuilder: (context, index) { + final page = _pages[index]; + return Padding( + padding: const EdgeInsets.all(40.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + page['icon'], + size: 100, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(height: 60), + Text( + page['title'], + style: Theme.of(context).textTheme.displayLarge?.copyWith(fontSize: 28), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + Text( + page['description'], + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7), + height: 1.5, + ), + textAlign: TextAlign.center, + ), + ], + ), + ); + }, + ), + ), + Padding( + padding: const EdgeInsets.all(40.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: List.generate( + _pages.length, + (index) => Container( + margin: const EdgeInsets.only(right: 8), + height: 8, + width: _currentPage == index ? 24 : 8, + decoration: BoxDecoration( + color: _currentPage == index + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.primary.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(4), + ), + ), + ), + ), + ElevatedButton( + onPressed: () { + if (_currentPage == _pages.length - 1) { + _completeOnboarding(); + } else { + _pageController.nextPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + }, + child: Text(_currentPage == _pages.length - 1 ? 'Get Started' : 'Next'), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/kifi-app/lib/features/transactions/data/models.dart b/kifi-app/lib/features/transactions/data/models.dart new file mode 100644 index 0000000..c2ad810 --- /dev/null +++ b/kifi-app/lib/features/transactions/data/models.dart @@ -0,0 +1,358 @@ +class Category { + final int id; + final String name; + final String? iconName; + + Category({required this.id, required this.name, this.iconName}); + + factory Category.fromJson(Map json) { + return Category( + id: json['id'], + name: json['name'], + iconName: json['iconName'], + ); + } + + Map toJson() => {'name': name, 'iconName': iconName}; +} + + +class TransactionItem { + final int? id; + final String name; + final double amount; + + TransactionItem({this.id, required this.name, required this.amount}); + + factory TransactionItem.fromJson(Map json) { + return TransactionItem( + id: json['id'], + name: json['name'], + amount: (json['amount'] is int) ? (json['amount'] as int).toDouble() : json['amount'], + ); + } + + Map toJson() => { + 'name': name, + 'amount': amount, + }; +} + +class TransactionAttachment { + final int id; + final String fileName; + final String filePath; + final String contentType; + + TransactionAttachment({required this.id, required this.fileName, required this.filePath, required this.contentType}); + + factory TransactionAttachment.fromJson(Map json) { + return TransactionAttachment( + id: json['id'], + fileName: json['fileName'], + filePath: json['filePath'], + contentType: json['contentType'], + ); + } +} + +class Transaction { + final int id; + final int? categoryId; + final int? fromWalletId; + final int? toWalletId; + final String? type; // 'INCOME' or 'EXPENSE' or 'INVESTMENT' (kept for backward compatibility during migration) + final double amount; + final DateTime date; + final String? description; + final String? notes; + final String? investmentStatus; + final double? maturityAmount; + final double? profitLoss; + final DateTime? closingDate; + final DateTime? dueDate; + final String? alertSchedule; + final String? alertTime; + final List? items; + final List? attachments; + + Transaction({ + required this.id, + this.categoryId, + this.fromWalletId, + this.toWalletId, + this.type, + required this.amount, + required this.date, + this.description, + this.notes, + this.investmentStatus, + this.maturityAmount, + this.profitLoss, + this.closingDate, + this.dueDate, + this.alertSchedule, + this.alertTime, + this.items, + this.attachments, + }); + + factory Transaction.fromJson(Map json) { + return Transaction( + id: json['id'], + categoryId: json['categoryId'], + fromWalletId: json['fromWalletId'], + toWalletId: json['toWalletId'], + type: json['type'], + amount: (json['amount'] is int) ? (json['amount'] as int).toDouble() : json['amount'], + date: DateTime.parse(json['date']), + description: json['description'], + notes: json['notes'], + investmentStatus: json['investmentStatus'], + maturityAmount: json['maturityAmount'] != null ? ((json['maturityAmount'] is int) ? (json['maturityAmount'] as int).toDouble() : json['maturityAmount']) : null, + profitLoss: json['profitLoss'] != null ? ((json['profitLoss'] is int) ? (json['profitLoss'] as int).toDouble() : json['profitLoss']) : null, + closingDate: json['closingDate'] != null ? DateTime.parse(json['closingDate']) : null, + dueDate: json['dueDate'] != null ? DateTime.parse(json['dueDate']) : null, + alertSchedule: json['alertSchedule'], + alertTime: json['alertTime'], + items: json['items'] != null ? (json['items'] as List).map((i) => TransactionItem.fromJson(i)).toList() : null, + attachments: json['attachments'] != null ? (json['attachments'] as List).map((i) => TransactionAttachment.fromJson(i)).toList() : null, + ); + } + + Map toJson() { + final map = { + 'categoryId': categoryId, + 'fromWalletId': fromWalletId, + 'toWalletId': toWalletId, + 'type': type, + 'amount': amount, + 'date': date.toIso8601String().split('T')[0], + 'description': description, + 'notes': notes, + 'investmentStatus': investmentStatus, + 'maturityAmount': maturityAmount, + 'profitLoss': profitLoss, + 'closingDate': closingDate?.toIso8601String().split('T')[0], + 'dueDate': dueDate?.toIso8601String().split('T')[0], + 'alertSchedule': alertSchedule, + 'alertTime': alertTime, + }; + if (items != null) { + map['items'] = items!.map((i) => i.toJson()).toList(); + } + return map; + } +} + +class Budget { + final int id; + final int? categoryId; + final int? walletId; + final double monthlyLimit; + final bool isShared; + + Budget({required this.id, this.categoryId, this.walletId, required this.monthlyLimit, this.isShared = false}); + + factory Budget.fromJson(Map json) { + double limit = 0.0; + if (json['monthlyLimit'] != null) { + if (json['monthlyLimit'] is num) { + limit = (json['monthlyLimit'] as num).toDouble(); + } else if (json['monthlyLimit'] is String) { + limit = double.tryParse(json['monthlyLimit']) ?? 0.0; + } + } + int parseId(dynamic val, [int defaultVal = 0]) { + if (val == null) return defaultVal; + if (val is int) return val; + if (val is num) return val.toInt(); + if (val is String) return int.tryParse(val) ?? double.tryParse(val)?.toInt() ?? defaultVal; + return defaultVal; + } + + return Budget( + id: parseId(json['id']), + categoryId: json['categoryId'] != null ? parseId(json['categoryId']) : null, + walletId: json['walletId'] != null ? parseId(json['walletId']) : null, + monthlyLimit: limit, + isShared: json['isShared'] ?? false, + ); + } + + Map toJson() => { + 'categoryId': categoryId, + 'walletId': walletId, + 'monthlyLimit': monthlyLimit, + 'isShared': isShared, + }; +} + +class RecurringTransaction { + final int id; + final int? categoryId; + final int? fromWalletId; + final int? toWalletId; + final String type; + final double amount; + final String frequency; // DAILY, WEEKLY, MONTHLY + final DateTime? nextExecutionDate; + final DateTime? endDate; + final String? status; + final String? description; + + RecurringTransaction({ + required this.id, + this.categoryId, + this.fromWalletId, + this.toWalletId, + required this.type, + required this.amount, + required this.frequency, + this.nextExecutionDate, + this.endDate, + this.status, + this.description, + }); + + factory RecurringTransaction.fromJson(Map json) { + return RecurringTransaction( + id: json['id'], + categoryId: json['categoryId'], + fromWalletId: json['fromWalletId'], + toWalletId: json['toWalletId'], + type: json['type'], + amount: (json['amount'] is int) ? (json['amount'] as int).toDouble() : json['amount'], + frequency: json['frequency'], + nextExecutionDate: json['nextExecutionDate'] != null ? DateTime.parse(json['nextExecutionDate']) : null, + endDate: json['endDate'] != null ? DateTime.parse(json['endDate']) : null, + status: json['status'], + description: json['description'], + ); + } + + Map toJson() { + return { + 'id': id, + 'categoryId': categoryId, + 'fromWalletId': fromWalletId, + 'toWalletId': toWalletId, + 'type': type, + 'amount': amount, + 'frequency': frequency, + 'nextExecutionDate': nextExecutionDate?.toIso8601String(), + 'endDate': endDate?.toIso8601String(), + 'status': status ?? 'ACTIVE', + 'description': description, + }; + } +} +class Wallet { + final int id; + final String name; + final int ownerId; + final String? nature; + final double balance; + final String? currency; + final String? icon; + final String? color; + + Wallet({ + required this.id, + required this.name, + required this.ownerId, + this.nature, + this.balance = 0.0, + this.currency, + this.icon, + this.color, + }); + + factory Wallet.fromJson(Map json) { + int parseId(dynamic val, [int defaultVal = 0]) { + if (val == null) return defaultVal; + if (val is int) return val; + if (val is num) return val.toInt(); + if (val is String) return int.tryParse(val) ?? double.tryParse(val)?.toInt() ?? defaultVal; + return defaultVal; + } + + double parseDouble(dynamic val, [double defaultVal = 0.0]) { + if (val == null) return defaultVal; + if (val is num) return val.toDouble(); + if (val is String) return double.tryParse(val) ?? defaultVal; + return defaultVal; + } + + return Wallet( + id: parseId(json['id']), + name: json['name'], + ownerId: parseId(json['ownerId']), + nature: json['nature'], + balance: parseDouble(json['balance']), + currency: json['currency'], + icon: json['icon'], + color: json['color'], + ); + } + + Map toJson() => { + 'name': name, + 'nature': nature, + 'currency': currency, + 'initialBalance': balance, // Note: backend uses initialBalance on creation + 'icon': icon, + 'color': color, + }; +} +class WalletInvitation { + final int id; + final int walletId; + final int inviterId; + final String inviteeEmail; + final String status; + final String createdAt; + + WalletInvitation({ + required this.id, + required this.walletId, + required this.inviterId, + required this.inviteeEmail, + required this.status, + required this.createdAt, + }); + + factory WalletInvitation.fromJson(Map json) { + return WalletInvitation( + id: json['id'], + walletId: json['walletId'], + inviterId: json['inviterId'], + inviteeEmail: json['inviteeEmail'], + status: json['status'], + createdAt: json['createdAt'] ?? '', + ); + } +} + +class WalletMember { + final int userId; + final String email; + final String role; + final String joinedAt; + + WalletMember({ + required this.userId, + required this.email, + required this.role, + required this.joinedAt, + }); + + factory WalletMember.fromJson(Map json) { + return WalletMember( + userId: json['userId'], + email: json['email'], + role: json['role'], + joinedAt: json['joinedAt'] ?? '', + ); + } +} diff --git a/kifi-app/lib/features/transactions/data/repository.dart b/kifi-app/lib/features/transactions/data/repository.dart new file mode 100644 index 0000000..ef341cb --- /dev/null +++ b/kifi-app/lib/features/transactions/data/repository.dart @@ -0,0 +1,220 @@ +import 'dart:convert'; +import 'package:dio/dio.dart'; +import '../../../../core/network/dio_client.dart'; +import 'models.dart'; + +class ApiRepository { + final Dio dio = DioClient().dio; + + Future> getCategories() async { + final response = await dio.get('/categories'); + return (response.data as List).map((j) => Category.fromJson(j)).toList(); + } + + Future addCategory(Category category) async { + final response = await dio.post('/categories', data: category.toJson()); + return Category.fromJson(response.data); + } + + Future> getTransactions() async { + final response = await dio.get('/transactions'); + return (response.data as List).map((j) => Transaction.fromJson(j)).toList(); + } + + Future> searchTransactions({ + String? type, + int? walletId, + int? categoryId, + String? startDate, + String? endDate, + String? search, + int page = 0, + int size = 20, + }) async { + final queryParams = { + if (type != null) 'type': type, + if (walletId != null) 'walletId': walletId, + if (categoryId != null) 'categoryId': categoryId, + if (startDate != null) 'startDate': startDate, + if (endDate != null) 'endDate': endDate, + if (search != null && search.isNotEmpty) 'search': search, + 'page': page, + 'size': size, + }; + + final response = await dio.get('/transactions/search', queryParameters: queryParams); + + final content = (response.data['content'] as List).map((j) => Transaction.fromJson(j)).toList(); + final totalElements = response.data['totalElements'] as int; + final totalPages = response.data['totalPages'] as int; + + return { + 'content': content, + 'totalElements': totalElements, + 'totalPages': totalPages, + }; + } + + Future addTransaction(Transaction transaction) async { + final response = await dio.post('/transactions', data: transaction.toJson()); + return Transaction.fromJson(response.data); + } + + Future updateTransaction(Transaction transaction) async { + final response = await dio.put('/transactions/${transaction.id}', data: transaction.toJson()); + return Transaction.fromJson(response.data); + } + + Future deleteTransaction(int id) async { + await dio.delete('/transactions/$id'); + } + + Future addAttachment(int transactionId, String fileName, String contentType, String base64Content) async { + final bytes = base64Decode(base64Content); + final formData = FormData.fromMap({ + 'file': MultipartFile.fromBytes(bytes, filename: fileName, contentType: DioMediaType.parse(contentType)), + }); + + final response = await dio.post('/transactions/$transactionId/attachments', data: formData); + return TransactionAttachment.fromJson(response.data); + } + + Future deleteAttachment(int attachmentId) async { + await dio.delete('/transactions/attachments/$attachmentId'); + } + + Future closeInvestment(int transactionId, double maturityAmount, DateTime closingDate, int toWalletId) async { + final response = await dio.put('/transactions/$transactionId/close-investment', data: { + 'maturityAmount': maturityAmount, + 'closingDate': closingDate.toIso8601String().split('T')[0], + 'toWalletId': toWalletId, + }); + return Transaction.fromJson(response.data); + } + + // Budgets + Future> getBudgets() async { + try { + final response = await dio.get('/budgets'); + if (response.data is List) { + return (response.data as List).map((j) => Budget.fromJson(j)).toList(); + } else if (response.data is Map && response.data.containsKey('data')) { + return (response.data['data'] as List).map((j) => Budget.fromJson(j)).toList(); + } + return []; + } catch (e) { + print('Error fetching budgets: $e'); + return []; + } + } + + Future addOrUpdateBudget(Budget budget) async { + final response = await dio.post('/budgets', data: budget.toJson()); + return Budget.fromJson(response.data); + } + + // Recurring Transactions + Future> getRecurringTransactions() async { + final response = await dio.get('/recurring-transactions'); + return (response.data as List).map((j) => RecurringTransaction.fromJson(j)).toList(); + } + + Future addRecurringTransaction(RecurringTransaction rt) async { + final response = await dio.post('/recurring-transactions', data: rt.toJson()); + return RecurringTransaction.fromJson(response.data); + } + + Future deleteRecurringTransaction(int id) async { + await dio.delete('/recurring-transactions/$id'); + } + + // Reports + Future> exportTransactions() async { + final response = await dio.get( + '/reports/export', + options: Options(responseType: ResponseType.bytes), + ); + return response.data; + } + + // Wallets + Future> getWallets() async { + final response = await dio.get('/wallets'); + return (response.data as List).map((j) => Wallet.fromJson(j)).toList(); + } + + Future createWallet({ + required String name, + String nature = 'CASH', + double initialBalance = 0.0, + String currency = 'INR', + String? icon, + String? color, + }) async { + final response = await dio.post('/wallets', data: { + 'name': name, + 'nature': nature, + 'initialBalance': initialBalance, + 'currency': currency, + 'icon': icon, + 'color': color, + }); + return Wallet.fromJson(response.data); + } + Future editWallet({ + required int id, + String? name, + String? nature, + String? icon, + String? color, + }) async { + final response = await dio.put('/wallets/$id', data: { + if (name != null) 'name': name, + if (nature != null) 'nature': nature, + if (icon != null) 'icon': icon, + if (color != null) 'color': color, + }); + return Wallet.fromJson(response.data); + } + + Future deleteWallet(int id) async { + try { + await dio.delete('/wallets/$id'); + } on DioException catch (e) { + if (e.response != null && e.response!.data != null && e.response!.data is Map) { + throw Exception(e.response!.data['error'] ?? 'Failed to delete wallet'); + } + throw Exception('Failed to delete wallet'); + } + } + Future inviteUserToWallet(int walletId, String email) async { + await dio.post('/wallets/$walletId/invite', data: {'email': email}); + } + + Future> getInvitations() async { + final response = await dio.get('/wallets/invitations'); + return (response.data as List).map((x) => WalletInvitation.fromJson(x)).toList(); + } + + Future acceptInvitation(int invitationId) async { + await dio.post('/wallets/invitations/$invitationId/accept'); + } + + Future rejectInvitation(int invitationId) async { + await dio.post('/wallets/invitations/$invitationId/reject'); + } + + Future> getWalletMembers(int walletId) async { + final response = await dio.get('/wallets/$walletId/members'); + return (response.data as List).map((x) => WalletMember.fromJson(x)).toList(); + } + + Future removeWalletMember(int walletId, int memberId) async { + await dio.delete('/wallets/$walletId/members/$memberId'); + } + + Future> getKnownContacts() async { + final response = await dio.get('/wallets/user-contacts'); + return (response.data as List).map((x) => x.toString()).toList(); + } +} diff --git a/kifi-app/lib/features/transactions/models/invitation.dart b/kifi-app/lib/features/transactions/models/invitation.dart new file mode 100644 index 0000000..41a72bf --- /dev/null +++ b/kifi-app/lib/features/transactions/models/invitation.dart @@ -0,0 +1,28 @@ +class WalletInvitation { + final int id; + final int walletId; + final int inviterId; + final String inviteeEmail; + final String status; + final String createdAt; + + WalletInvitation({ + required this.id, + required this.walletId, + required this.inviterId, + required this.inviteeEmail, + required this.status, + required this.createdAt, + }); + + factory WalletInvitation.fromJson(Map json) { + return WalletInvitation( + id: json['id'], + walletId: json['walletId'], + inviterId: json['inviterId'], + inviteeEmail: json['inviteeEmail'], + status: json['status'], + createdAt: json['createdAt'] ?? '', + ); + } +} diff --git a/kifi-app/lib/features/transactions/presentation/add_transaction_screen.dart b/kifi-app/lib/features/transactions/presentation/add_transaction_screen.dart new file mode 100644 index 0000000..00e8d37 --- /dev/null +++ b/kifi-app/lib/features/transactions/presentation/add_transaction_screen.dart @@ -0,0 +1,1110 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'widgets/attachment_gallery_screen.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import 'package:intl/intl.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart' as flutter_secure_storage; +import 'package:image_picker/image_picker.dart'; +import 'package:flutter_image_compress/flutter_image_compress.dart'; +import '../providers/providers.dart'; +import '../data/models.dart'; +import '../../../core/utils/snackbar_service.dart'; +import '../../../core/services/ocr_service.dart'; +import '../../../core/services/notification_service.dart'; + +class AddTransactionScreen extends ConsumerStatefulWidget { + final Transaction? transaction; + + const AddTransactionScreen({super.key, this.transaction}); + + @override + ConsumerState createState() => _AddTransactionScreenState(); +} + +class _AddTransactionScreenState extends ConsumerState { + final amountController = TextEditingController(); + final descriptionController = TextEditingController(); + DateTime selectedDate = DateTime.now(); + Category? selectedCategory; + Wallet? fromWallet; + Wallet? toWallet; + bool isSaving = false; + + bool isRecurring = false; + String recurringFrequency = 'MONTHLY'; + final List frequencies = ['DAILY', 'WEEKLY', 'MONTHLY']; + + DateTime? dueDate; + String alertSchedule = 'NONE'; + TimeOfDay alertTime = const TimeOfDay(hour: 9, minute: 0); + + final List lineItems = []; + final List existingAttachments = []; + final List deletedAttachmentIds = []; + final List> base64Attachments = []; + final ImagePicker _picker = ImagePicker(); + String? _jwtToken; + + final OcrService _ocrService = OcrService(); + + void _handleOcrResult(OcrResult? res) { + if (res == null) return; + if (res.amount != null) { + setState(() { + amountController.text = res.amount.toString(); + }); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Found amount: Rs. ${res.amount}'))); + } else { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Could not find an amount in the receipt.'))); + } + } + + @override + void initState() { + super.initState(); + const flutter_secure_storage.FlutterSecureStorage().read(key: 'jwt_token').then((value) { + if (mounted) setState(() => _jwtToken = value); + }); + if (widget.transaction != null) { + final t = widget.transaction!; + amountController.text = t.amount.toString(); + if (t.description != null) descriptionController.text = t.description!; + selectedDate = t.date; + if (t.items != null) lineItems.addAll(t.items!); + if (t.attachments != null) existingAttachments.addAll(t.attachments!); + dueDate = t.dueDate; + alertSchedule = t.alertSchedule ?? 'NONE'; + if (t.alertTime != null) { + final parts = t.alertTime!.split(':'); + if (parts.length >= 2) { + alertTime = TimeOfDay(hour: int.tryParse(parts[0]) ?? 9, minute: int.tryParse(parts[1]) ?? 0); + } + } + } + } + + void _resolveInitialSelections(List? categories, List? wallets) { + if (widget.transaction != null && selectedCategory == null && categories != null) { + final matches = categories.where((c) => c.id == widget.transaction!.categoryId); + if (matches.isNotEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) setState(() => selectedCategory = matches.first); + }); + } + } + + if (wallets != null && wallets.isNotEmpty && (fromWallet == null || toWallet == null)) { + if (widget.transaction != null) { + final fMatches = wallets.where((w) => w.id == widget.transaction!.fromWalletId); + if (fMatches.isNotEmpty && fromWallet == null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) setState(() => fromWallet = fMatches.first); + }); + } + final tMatches = wallets.where((w) => w.id == widget.transaction!.toWalletId); + if (tMatches.isNotEmpty && toWallet == null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) setState(() => toWallet = tMatches.first); + }); + } + } + } + + if (widget.transaction == null) { + if (categories != null && categories.length == 1 && selectedCategory == null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) setState(() => selectedCategory = categories.first); + }); + } + if (wallets != null && wallets.length == 1 && toWallet == null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) setState(() => toWallet = wallets.first); + }); + } + } + } + + Future _pickImage(ImageSource source) async { + if ((base64Attachments.length + existingAttachments.length) >= 3) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Max 3 attachments allowed'))); + return; + } + final XFile? image = await _picker.pickImage(source: source); + if (image != null) { + final bytes = await image.readAsBytes(); + final compressedBytes = await FlutterImageCompress.compressWithList( + bytes, + minWidth: 600, + quality: 80, + ); + final base64String = base64Encode(compressedBytes); + setState(() { + base64Attachments.add({ + 'fileName': image.name, + 'contentType': 'image/jpeg', + 'base64Content': base64String, + }); + }); + } + } + + void _addLineItem() { + final nameCtrl = TextEditingController(); + final amountCtrl = TextEditingController(); + showDialog( + context: context, + builder: (ctx) => Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)), + elevation: 0, + backgroundColor: Colors.transparent, + child: GestureDetector( + onTap: () => FocusScope.of(context).unfocus(), + child: Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(28), + boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 20, offset: const Offset(0, 10))], + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Add Item', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + const SizedBox(height: 24), + TextField( + controller: nameCtrl, + style: const TextStyle(fontWeight: FontWeight.w500), + decoration: InputDecoration( + labelText: 'Item Name', + filled: true, + fillColor: Colors.grey.shade100, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)), + ) + ), + const SizedBox(height: 16), + TextField( + controller: amountCtrl, + keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: false), + textInputAction: TextInputAction.done, + style: const TextStyle(fontWeight: FontWeight.w500), + decoration: InputDecoration( + labelText: 'Amount', + prefixText: 'Rs. ', + prefixStyle: const TextStyle(color: Colors.black87, fontWeight: FontWeight.w500, fontSize: 16), + filled: true, + fillColor: Colors.grey.shade100, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)), + ) + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + style: TextButton.styleFrom(foregroundColor: Colors.grey.shade700, padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12))), + child: const Text('Cancel', style: TextStyle(fontWeight: FontWeight.bold)) + ), + const SizedBox(width: 12), + ElevatedButton( + onPressed: () { + if (nameCtrl.text.isNotEmpty && amountCtrl.text.isNotEmpty) { + setState(() { + lineItems.add(TransactionItem(name: nameCtrl.text, amount: double.parse(amountCtrl.text))); + double currentTotal = double.tryParse(amountController.text) ?? 0; + amountController.text = (currentTotal + double.parse(amountCtrl.text)).toString(); + }); + Navigator.pop(ctx); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF6C63FF), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + elevation: 0, + ), + child: const Text('Add', style: TextStyle(fontWeight: FontWeight.bold)) + ), + ], + ), + ], + ), + ), + ), + ), + ), + ); + } + + Future _showCreateWalletDialog() async { + final ctrl = TextEditingController(); + String selectedNature = 'CASH'; + final natures = ['CASH', 'SAVINGS', 'INVESTMENTS', 'LOAN', 'LENDING', 'PAYABLES', 'INCOME', 'EXPENSE']; + + await showDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (context, setStateDialog) { + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)), + elevation: 0, + backgroundColor: Colors.transparent, + child: GestureDetector( + onTap: () => FocusScope.of(context).unfocus(), + child: Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(28), + boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 20, offset: const Offset(0, 10))], + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration(color: const Color(0xFF6C63FF).withValues(alpha: 0.1), shape: BoxShape.circle), + child: const Icon(LucideIcons.wallet, color: Color(0xFF6C63FF), size: 24), + ), + const SizedBox(width: 16), + const Text('New Account', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + ], + ), + const SizedBox(height: 24), + TextField( + controller: ctrl, + style: const TextStyle(fontWeight: FontWeight.w500), + decoration: InputDecoration( + hintText: 'Account Name (e.g. Household)', + filled: true, + fillColor: Colors.grey.shade100, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)), + ), + ), + const SizedBox(height: 16), + DropdownButtonFormField( + value: selectedNature, + style: const TextStyle(fontWeight: FontWeight.w500, color: Colors.black87), + decoration: InputDecoration( + labelText: 'Account Nature', + filled: true, + fillColor: Colors.grey.shade100, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)), + ), + items: natures.map((n) => DropdownMenuItem(value: n, child: Text(n))).toList(), + onChanged: (val) { + if (val != null) setStateDialog(() => selectedNature = val); + }, + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + style: TextButton.styleFrom(foregroundColor: Colors.grey.shade700, padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12))), + child: const Text('Cancel', style: TextStyle(fontWeight: FontWeight.bold)), + ), + const SizedBox(width: 12), + ElevatedButton( + onPressed: () async { + if (ctrl.text.isNotEmpty) { + await ref.read(walletProvider.notifier).createWallet(name: ctrl.text, nature: selectedNature); + if (context.mounted) Navigator.pop(ctx); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF6C63FF), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + elevation: 0, + ), + child: const Text('Create', style: TextStyle(fontWeight: FontWeight.bold)), + ) + ], + ), + ], + ), + ), + ), + ), + ); + } + ), + ); + } + + void _showCategoryPicker() { + showModalBottomSheet( + context: context, + builder: (ctx) { + return Consumer(builder: (context, ref, child) { + final categoriesState = ref.watch(categoryProvider); + return Column( + children: [ + ListTile( + title: const Text('Select Category', style: TextStyle(fontWeight: FontWeight.bold)), + trailing: IconButton( + icon: const Icon(LucideIcons.plus), + onPressed: () => _showQuickAdd('Category'), + ), + ), + Expanded( + child: categoriesState.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, st) => Center(child: Text('Error: $e')), + data: (categories) => ListView.builder( + itemCount: categories.length, + itemBuilder: (context, index) { + final c = categories[index]; + return ListTile( + leading: const Icon(LucideIcons.tag), + title: Text(c.name), + onTap: () { + setState(() => selectedCategory = c); + Navigator.pop(context); + }, + ); + }, + ), + ), + ) + ], + ); + }); + }, + ); + } + + void _showWalletPicker(bool isDestination) { + showModalBottomSheet( + context: context, + builder: (ctx) { + return Consumer(builder: (context, ref, child) { + final walletsState = ref.watch(walletProvider); + return Column( + children: [ + ListTile( + title: Text(isDestination ? 'Select To Account' : 'Select From Account', style: const TextStyle(fontWeight: FontWeight.bold)), + leading: IconButton( + icon: const Icon(LucideIcons.plus), + tooltip: 'Create New Account', + onPressed: () { + Navigator.pop(context); // Close picker + _showCreateWalletDialog(); + }, + ), + trailing: IconButton( + icon: const Icon(LucideIcons.x), + onPressed: () { + if (isDestination) { + setState(() => toWallet = null); + } else { + setState(() => fromWallet = null); + } + Navigator.pop(context); + }, + ), + ), + Expanded( + child: walletsState.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, st) => Center(child: Text('Error: $e')), + data: (wallets) { + return ListView.builder( + itemCount: wallets.length, + itemBuilder: (context, index) { + final w = wallets[index]; + return ListTile( + leading: const Icon(LucideIcons.wallet), + title: Text(w.name), + subtitle: Text('${w.nature ?? "CASH"} • Balance: Rs. ${w.balance}'), + onTap: () { + if (isDestination) { + setState(() => toWallet = w); + } else { + setState(() => fromWallet = w); + } + Navigator.pop(context); + }, + ); + }, + ); + } + ), + ) + ], + ); + }); + }, + ); + } + + Future _pickDate() async { + final picked = await showDatePicker( + context: context, + initialDate: selectedDate, + firstDate: DateTime(2000), + lastDate: DateTime(2101), + ); + if (picked != null && picked != selectedDate) { + setState(() { + selectedDate = picked; + }); + } + } + + void _showQuickAdd(String type) { + Navigator.pop(context); + final ctrl = TextEditingController(); + showDialog( + context: context, + builder: (ctx) => Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)), + elevation: 0, + backgroundColor: Colors.transparent, + child: GestureDetector( + onTap: () => FocusScope.of(context).unfocus(), + child: Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(28), + boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 20, offset: const Offset(0, 10))], + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Add New $type', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + const SizedBox(height: 24), + TextField( + controller: ctrl, + textInputAction: TextInputAction.done, + style: const TextStyle(fontWeight: FontWeight.w500), + decoration: InputDecoration( + hintText: 'Name', + filled: true, + fillColor: Colors.grey.shade100, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)), + ), + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + style: TextButton.styleFrom(foregroundColor: Colors.grey.shade700, padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12))), + child: const Text('Cancel', style: TextStyle(fontWeight: FontWeight.bold)), + ), + const SizedBox(width: 12), + ElevatedButton( + onPressed: () async { + if (ctrl.text.isEmpty) return; + if (type == 'Category') { + await ref.read(categoryProvider.notifier).addCategory(ctrl.text, 'tag'); + } + if (mounted) Navigator.pop(ctx); + }, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF6C63FF), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + elevation: 0, + ), + child: const Text('Add', style: TextStyle(fontWeight: FontWeight.bold)), + ) + ], + ), + ], + ), + ), + ), + ), + ), + ); + } + + Future _saveTransaction() async { + final amountText = amountController.text.trim(); + if (amountText.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Please enter an amount'))); + return; + } + + if (toWallet == null) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Please select a To Account'))); + return; + } + + // Check if From Account is mandatory + bool fromAccountOptional = (toWallet?.nature == 'SAVINGS' || toWallet?.nature == 'INCOME'); + if (fromWallet == null && !fromAccountOptional) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Please select a From Account'))); + return; + } + + if (fromWallet != null && toWallet != null && fromWallet?.id == toWallet?.id) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('From and To accounts cannot be the same'))); + return; + } + + final amount = double.tryParse(amountText); + if (amount == null) return; + + if (fromWallet != null && fromWallet!.balance < amount) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Insufficient balance in From Account'))); + return; + } + + setState(() => isSaving = true); + + // Determine internal type based on natures + String type = 'TRANSFER'; + if (fromWallet == null) { + type = 'INCOME'; + } else if (toWallet == null) { // Though toWallet is mandatory, just in case + type = 'EXPENSE'; + } else if (fromWallet?.nature == 'INCOME') { + type = 'INCOME'; + } else if (toWallet?.nature == 'PAYABLES' || toWallet?.nature == 'EXPENSE') { + type = 'EXPENSE'; + } else if (toWallet?.nature == 'INVESTMENTS') { + type = 'INVESTMENT'; + } + + if (isRecurring && widget.transaction == null) { + DateTime nextDate = selectedDate; + if (recurringFrequency == 'DAILY') { + nextDate = nextDate.add(const Duration(days: 1)); + } else if (recurringFrequency == 'WEEKLY') { + nextDate = nextDate.add(const Duration(days: 7)); + } else if (recurringFrequency == 'MONTHLY') { + nextDate = DateTime(nextDate.year, nextDate.month + 1, nextDate.day); + } + + final rt = RecurringTransaction( + id: 0, + type: type, + amount: amount, + categoryId: selectedCategory?.id, + fromWalletId: fromWallet?.id, + toWalletId: toWallet?.id, + frequency: recurringFrequency, + description: descriptionController.text.trim(), + nextExecutionDate: nextDate, + status: 'ACTIVE', + ); + await ref.read(recurringTransactionProvider.notifier).addRecurringTransaction(rt); + + final tx = Transaction( + id: 0, + type: type, + amount: amount, + date: selectedDate, + description: descriptionController.text.trim(), + categoryId: selectedCategory?.id, + fromWalletId: fromWallet?.id, + toWalletId: toWallet?.id, + dueDate: dueDate, + alertSchedule: alertSchedule == 'NONE' ? null : alertSchedule, + alertTime: alertSchedule == 'NONE' ? null : '${alertTime.hour.toString().padLeft(2, '0')}:${alertTime.minute.toString().padLeft(2, '0')}:00', + items: lineItems.isNotEmpty ? lineItems : null, + ); + await ref.read(transactionProvider.notifier).addTransaction(tx, base64Attachments: base64Attachments); + } else { + final tx = Transaction( + id: widget.transaction?.id ?? 0, + type: type, + amount: amount, + date: selectedDate, + description: descriptionController.text.trim(), + categoryId: selectedCategory?.id, + fromWalletId: fromWallet?.id, + toWalletId: toWallet?.id, + dueDate: dueDate, + alertSchedule: alertSchedule == 'NONE' ? null : alertSchedule, + alertTime: alertSchedule == 'NONE' ? null : '${alertTime.hour.toString().padLeft(2, '0')}:${alertTime.minute.toString().padLeft(2, '0')}:00', + items: lineItems.isNotEmpty ? lineItems : null, + ); + + if (widget.transaction == null) { + await ref.read(transactionProvider.notifier).addTransaction(tx, base64Attachments: base64Attachments); + } else { + await ref.read(transactionProvider.notifier).updateTransaction( + tx, + base64Attachments: base64Attachments, + deletedAttachmentIds: deletedAttachmentIds, + ); + } + + if (alertSchedule != 'NONE' && dueDate != null) { + DateTime alertDate = dueDate!; + if (alertSchedule == '1_DAY_BEFORE') { + alertDate = dueDate!.subtract(const Duration(days: 1)); + } else if (alertSchedule == '2_DAYS_BEFORE') { + alertDate = dueDate!.subtract(const Duration(days: 2)); + } else if (alertSchedule == '1_WEEK_BEFORE') { + alertDate = dueDate!.subtract(const Duration(days: 7)); + } + + final notifyDate = DateTime(alertDate.year, alertDate.month, alertDate.day, alertTime.hour, alertTime.minute); + if (notifyDate.isAfter(DateTime.now())) { + NotificationService().scheduleNotification( + id: DateTime.now().millisecondsSinceEpoch.remainder(100000), + title: 'Payment Due!', + body: 'Rs. ${amount.toStringAsFixed(0)} is due for ${toWallet?.name}', + scheduledDate: notifyDate, + ); + } + } + } + + if (mounted) { + SnackBarService.showSuccess(context, 'Transaction saved successfully'); + Navigator.pop(context); + } + } + + @override + Widget build(BuildContext context) { + final categoriesState = ref.watch(categoryProvider); + final walletsState = ref.watch(walletProvider); + + _resolveInitialSelections(categoriesState.value, walletsState.value); + + return Scaffold( + appBar: AppBar( + title: Text(widget.transaction == null ? 'Add Transaction' : 'Edit Transaction'), + backgroundColor: Colors.transparent, + elevation: 0, + actions: [ + IconButton( + icon: const Icon(LucideIcons.camera), + tooltip: 'Scan Receipt', + onPressed: () async { + showModalBottomSheet( + context: context, + builder: (ctx) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(LucideIcons.camera), + title: const Text('Take a Photo'), + onTap: () async { + Navigator.pop(ctx); + final res = await _ocrService.scanReceiptFromCamera(); + _handleOcrResult(res); + }, + ), + ListTile( + leading: const Icon(LucideIcons.image), + title: const Text('Choose from Gallery'), + onTap: () async { + Navigator.pop(ctx); + final res = await _ocrService.scanReceiptFromGallery(); + _handleOcrResult(res); + }, + ), + ], + ), + ), + ); + }, + ) + ], + ), + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: amountController, + keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: false), + textInputAction: TextInputAction.next, + style: const TextStyle(fontSize: 32, fontWeight: FontWeight.bold, color: Color(0xFF6C63FF)), + textAlign: TextAlign.center, + decoration: InputDecoration( + hintText: '0.00', + hintStyle: TextStyle(color: Colors.grey.shade400), + prefixText: 'Rs. ', + prefixStyle: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Colors.black87), + filled: true, + fillColor: const Color(0xFF6C63FF).withValues(alpha: 0.05), + contentPadding: const EdgeInsets.symmetric(vertical: 24, horizontal: 24), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(24), borderSide: BorderSide.none), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(24), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)), + ), + ), + const SizedBox(height: 24), + TextField( + controller: descriptionController, + textInputAction: TextInputAction.done, + style: const TextStyle(fontWeight: FontWeight.w500), + decoration: InputDecoration( + hintText: 'Description (Optional)', + prefixIcon: const Icon(LucideIcons.alignLeft, color: Colors.grey), + filled: true, + fillColor: Colors.grey.shade100, + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)), + ), + ), + const SizedBox(height: 16), + ListTile( + title: Text(DateFormat('dd MMM yyyy').format(selectedDate)), + trailing: const Icon(LucideIcons.calendar), + onTap: _pickDate, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12), side: BorderSide(color: Colors.grey.shade300)), + ), + const SizedBox(height: 12), + + Tooltip( + message: 'The destination account where the money is going to.', + child: ListTile( + title: Text(toWallet?.name ?? 'To Account'), + subtitle: toWallet != null ? Text(toWallet!.nature ?? 'CASH') : null, + trailing: const Icon(LucideIcons.chevronRight), + onTap: () => _showWalletPicker(true), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12), side: BorderSide(color: Colors.grey.shade300)), + ), + ), + const SizedBox(height: 12), + + Tooltip( + message: 'The source account where the money is coming from.', + child: ListTile( + title: Text(fromWallet?.name ?? 'From Account${(toWallet?.nature == "SAVINGS" || toWallet?.nature == "INCOME") ? " (Optional)" : ""}'), + subtitle: fromWallet != null ? Text(fromWallet!.nature ?? 'CASH') : null, + trailing: const Icon(LucideIcons.chevronRight), + onTap: () => _showWalletPicker(false), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12), side: BorderSide(color: Colors.grey.shade300)), + ), + ), + const SizedBox(height: 12), + + ListTile( + title: Text(selectedCategory?.name ?? 'Select Category (Optional)'), + trailing: const Icon(LucideIcons.chevronRight), + onTap: _showCategoryPicker, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12), side: BorderSide(color: Colors.grey.shade300)), + ), + const SizedBox(height: 12), + + if (toWallet?.nature == 'PAYABLES' || toWallet?.nature == 'LOAN') ...[ + ListTile( + title: Text(dueDate == null ? 'Set Due Date (Optional)' : 'Due Date: ${DateFormat('dd MMM yyyy').format(dueDate!)}'), + trailing: const Icon(LucideIcons.calendar), + onTap: () async { + final picked = await showDatePicker( + context: context, + initialDate: dueDate ?? DateTime.now(), + firstDate: DateTime.now().subtract(const Duration(days: 30)), + lastDate: DateTime.now().add(const Duration(days: 365 * 5)), + ); + if (picked != null) { + setState(() => dueDate = picked); + } + }, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12), side: BorderSide(color: Colors.grey.shade300)), + ), + if (dueDate != null) ...[ + const SizedBox(height: 8), + DropdownButtonFormField( + value: alertSchedule, + decoration: InputDecoration( + labelText: 'Alert Notification', + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ), + items: const [ + DropdownMenuItem(value: 'NONE', child: Text('No Alert')), + DropdownMenuItem(value: 'ON_DUE_DATE', child: Text('On Due Date')), + DropdownMenuItem(value: '1_DAY_BEFORE', child: Text('1 Day Before')), + DropdownMenuItem(value: '2_DAYS_BEFORE', child: Text('2 Days Before')), + DropdownMenuItem(value: '1_WEEK_BEFORE', child: Text('1 Week Before')), + ], + onChanged: (val) { + if (val != null) setState(() => alertSchedule = val); + }, + ), + if (alertSchedule != 'NONE') ...[ + const SizedBox(height: 8), + ListTile( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12), side: BorderSide(color: Colors.grey.shade300)), + title: const Text('Alert Time'), + trailing: Text(alertTime.format(context), style: const TextStyle(fontWeight: FontWeight.bold)), + onTap: () async { + final time = await showTimePicker( + context: context, + initialTime: alertTime, + ); + if (time != null) { + setState(() => alertTime = time); + } + }, + ), + ], + ], + const SizedBox(height: 12), + ], + + // Line Items Section + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Items', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), + TextButton.icon( + onPressed: _addLineItem, + icon: const Icon(LucideIcons.plus, size: 16), + label: const Text('Add Item'), + ) + ], + ), + if (lineItems.isNotEmpty) + Container( + decoration: BoxDecoration(border: Border.all(color: Colors.grey.shade300), borderRadius: BorderRadius.circular(12)), + child: ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: lineItems.length, + separatorBuilder: (c, i) => const Divider(height: 1), + itemBuilder: (c, i) { + final item = lineItems[i]; + return ListTile( + title: Text(item.name), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text('Rs. ${item.amount}', style: const TextStyle(fontWeight: FontWeight.bold)), + IconButton( + icon: const Icon(LucideIcons.trash2, color: Colors.red, size: 18), + onPressed: () => setState(() => lineItems.removeAt(i)), + ) + ], + ), + ); + }, + ), + ), + const SizedBox(height: 12), + + // Attachments Section + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Attachments (${base64Attachments.length}/3)', style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), + if (base64Attachments.length < 3) + TextButton.icon( + onPressed: () { + showModalBottomSheet( + context: context, + builder: (ctx) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(LucideIcons.camera), + title: const Text('Camera'), + onTap: () { + Navigator.pop(ctx); + _pickImage(ImageSource.camera); + }, + ), + ListTile( + leading: const Icon(LucideIcons.image), + title: const Text('Gallery'), + onTap: () { + Navigator.pop(ctx); + _pickImage(ImageSource.gallery); + }, + ), + ], + ), + ), + ); + }, + icon: const Icon(LucideIcons.paperclip, size: 16), + label: const Text('Attach'), + ) + ], + ), + if (existingAttachments.isNotEmpty) + SizedBox( + height: 100, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: existingAttachments.length, + itemBuilder: (c, i) { + final att = existingAttachments[i]; + return Stack( + children: [ + GestureDetector( + onTap: () => _openGallery(i), + child: Container( + margin: const EdgeInsets.only(right: 8, top: 8), + width: 80, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + image: DecorationImage( + image: _jwtToken != null + ? NetworkImage('https://app.technobeesolutions.in/api/kifi/transactions/${widget.transaction!.id}/attachments/${att.id}/content', headers: {'Authorization': 'Bearer $_jwtToken'}) + : const AssetImage('assets/images/placeholder.png') as ImageProvider, + fit: BoxFit.cover, + ), + ), + ), + ), + Positioned( + top: 0, + right: 0, + child: GestureDetector( + onTap: () { + setState(() { + deletedAttachmentIds.add(att.id); + existingAttachments.removeAt(i); + }); + }, + child: Container( + padding: const EdgeInsets.all(2), + decoration: const BoxDecoration(color: Colors.red, shape: BoxShape.circle), + child: const Icon(LucideIcons.x, size: 12, color: Colors.white), + ), + ), + ), + ], + ); + }, + ), + ), + if (base64Attachments.isNotEmpty) + SizedBox( + height: 100, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: base64Attachments.length, + itemBuilder: (c, i) { + final att = base64Attachments[i]; + return Stack( + children: [ + GestureDetector( + onTap: () => _openGallery(existingAttachments.length + i), + child: Container( + margin: const EdgeInsets.only(right: 8, top: 8), + width: 80, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + image: DecorationImage( + image: MemoryImage(base64Decode(att['base64Content']!)), + fit: BoxFit.cover, + ), + ), + ), + ), + Positioned( + top: 0, + right: 0, + child: GestureDetector( + onTap: () => setState(() => base64Attachments.removeAt(i)), + child: Container( + padding: const EdgeInsets.all(2), + decoration: const BoxDecoration(color: Colors.red, shape: BoxShape.circle), + child: const Icon(LucideIcons.x, size: 12, color: Colors.white), + ), + ), + ), + ], + ); + }, + ), + ), + const SizedBox(height: 24), + if (widget.transaction == null) + SwitchListTile( + title: const Text('Recurring Transaction'), + value: isRecurring, + activeColor: Theme.of(context).colorScheme.primary, + onChanged: (val) => setState(() => isRecurring = val), + ), + if (isRecurring && widget.transaction == null) + DropdownButtonFormField( + value: recurringFrequency, + decoration: InputDecoration( + labelText: 'Frequency', + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + ), + items: frequencies.map((f) => DropdownMenuItem(value: f, child: Text(f))).toList(), + onChanged: (val) { + if (val != null) setState(() => recurringFrequency = val); + }, + ), + const SizedBox(height: 32), + SizedBox( + height: 56, + child: ElevatedButton( + onPressed: isSaving ? null : _saveTransaction, + style: ElevatedButton.styleFrom( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + ), + child: isSaving + ? const CircularProgressIndicator() + : const Text('Save Transaction', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + ), + ), + const SizedBox(height: 32), + ], + ), + ), + ), + ); + } + + void _openGallery(int index) { + final List allImages = []; + + // Add existing attachments + for (final att in existingAttachments) { + if (_jwtToken != null) { + allImages.add(NetworkImage('https://app.technobeesolutions.in/api/kifi/transactions/${widget.transaction!.id}/attachments/${att.id}/content', headers: {'Authorization': 'Bearer $_jwtToken'})); + } else { + allImages.add(const AssetImage('assets/images/placeholder.png')); + } + } + + // Add new attachments + for (final att in base64Attachments) { + allImages.add(MemoryImage(base64Decode(att['base64Content']!))); + } + + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => AttachmentGalleryScreen( + images: allImages, + initialIndex: index, + ), + ), + ); + } +} diff --git a/kifi-app/lib/features/transactions/presentation/all_transactions_screen.dart b/kifi-app/lib/features/transactions/presentation/all_transactions_screen.dart new file mode 100644 index 0000000..73b011f --- /dev/null +++ b/kifi-app/lib/features/transactions/presentation/all_transactions_screen.dart @@ -0,0 +1,308 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import 'package:intl/intl.dart'; +import '../providers/providers.dart'; +import '../../dashboard/presentation/maturity_dialog.dart'; +import '../providers/paginated_transaction_provider.dart'; +import '../../../core/widgets/shimmer_loading.dart'; +import '../../../core/widgets/empty_state.dart'; +import 'add_transaction_screen.dart'; +import '../presentation/widgets/transaction_filter_sheet.dart'; +import '../../../core/theme/nature_colors.dart'; + +class AllTransactionsScreen extends ConsumerStatefulWidget { + const AllTransactionsScreen({super.key}); + + @override + ConsumerState createState() => _AllTransactionsScreenState(); +} + +class _AllTransactionsScreenState extends ConsumerState { + final ScrollController _scrollController = ScrollController(); + final TextEditingController _searchController = TextEditingController(); + + @override + void initState() { + super.initState(); + _scrollController.addListener(() { + if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) { + ref.read(paginatedTransactionProvider.notifier).loadMore(); + } + }); + } + + @override + void dispose() { + _scrollController.dispose(); + _searchController.dispose(); + super.dispose(); + } + + void _onSearchChanged(String value) { + // Basic debounce for text search + Future.delayed(const Duration(milliseconds: 500), () { + if (_searchController.text == value) { + ref.read(paginatedTransactionProvider.notifier).updateFilters(search: value); + } + }); + } + + void _openFilterSheet() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => const TransactionFilterSheet(), + ); + } + + @override + Widget build(BuildContext context) { + final paginatedState = ref.watch(paginatedTransactionProvider); + final transactions = paginatedState.transactions; + final categoriesState = ref.watch(categoryProvider); + final walletsState = ref.watch(walletProvider); + + return Scaffold( + appBar: AppBar( + title: const Text('Transactions'), + actions: [ + IconButton( + icon: const Icon(LucideIcons.filter), + onPressed: _openFilterSheet, + ), + IconButton( + icon: const Icon(LucideIcons.plus), + onPressed: () { + Navigator.push(context, MaterialPageRoute(builder: (_) => const AddTransactionScreen())); + }, + ), + ], + bottom: PreferredSize( + preferredSize: const Size.fromHeight(60), + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: TextField( + controller: _searchController, + onChanged: _onSearchChanged, + decoration: InputDecoration( + hintText: 'Search transactions...', + prefixIcon: const Icon(LucideIcons.search), + suffixIcon: _searchController.text.isNotEmpty ? IconButton( + icon: const Icon(LucideIcons.x), + onPressed: () { + _searchController.clear(); + _onSearchChanged(''); + }, + ) : null, + filled: true, + fillColor: Colors.grey.shade100, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2) + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ), + ), + ), + ), + ), + body: Builder( + builder: (context) { + if (transactions.isEmpty && paginatedState.isLoading) { + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: 8, + itemBuilder: (context, index) => const ShimmerCard(), + ); + } + + if (transactions.isEmpty) { + return const EmptyStateWidget( + icon: LucideIcons.fileText, + title: 'No Transactions', + message: 'No transactions found matching your criteria.', + ); + } + + return RefreshIndicator( + onRefresh: () async { + ref.read(paginatedTransactionProvider.notifier).clearFilters(); + _searchController.clear(); + }, + child: ListView.builder( + controller: _scrollController, + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(16.0), + itemCount: transactions.length + (paginatedState.hasMore ? 1 : 0), + itemBuilder: (context, index) { + if (index == transactions.length) { + return const Padding( + padding: EdgeInsets.all(16.0), + child: Center(child: CircularProgressIndicator()), + ); + } + + final t = transactions[index]; + final isIncome = t.type == 'INCOME' || (t.type == 'TRANSFER' && t.fromWalletId == null); + final isInvestment = t.type == 'INVESTMENT'; + final isExpense = t.type == 'EXPENSE' || (t.type == 'TRANSFER' && t.toWalletId == null); + final isTransfer = t.type == 'TRANSFER' && t.fromWalletId != null && t.toWalletId != null; + + Color typeColor = Colors.grey; + IconData typeIcon = LucideIcons.arrowRightLeft; + + if (isIncome) { + typeColor = NatureColors.getColor('INCOME'); + typeIcon = LucideIcons.arrowDownCircle; + } else if (isExpense) { + typeColor = NatureColors.getColor('EXPENSE'); + typeIcon = LucideIcons.arrowUpCircle; + } else if (isInvestment) { + typeColor = NatureColors.getColor('INVESTMENTS'); + typeIcon = LucideIcons.trendingUp; + } else if (isTransfer) { + typeColor = NatureColors.getColor('TRANSFER'); + if (walletsState.hasValue) { + final toW = walletsState.value!.where((w) => w.id == t.toWalletId).firstOrNull; + final fromW = walletsState.value!.where((w) => w.id == t.fromWalletId).firstOrNull; + + if (toW != null && (toW.nature == 'PAYABLES' || toW.nature == 'LOAN')) { + typeColor = NatureColors.getColor('PAYABLES'); + typeIcon = LucideIcons.alertCircle; + } else if (toW != null && (toW.nature == 'RECEIVABLES' || toW.nature == 'LENDING')) { + typeColor = NatureColors.getColor('RECEIVABLES'); + typeIcon = LucideIcons.arrowDownLeft; + } else if (fromW != null && (fromW.nature == 'PAYABLES' || fromW.nature == 'LOAN')) { + typeColor = NatureColors.getColor('PAYABLES'); + typeIcon = LucideIcons.alertCircle; + } else if (fromW != null && (fromW.nature == 'RECEIVABLES' || fromW.nature == 'LENDING')) { + typeColor = NatureColors.getColor('RECEIVABLES'); + typeIcon = LucideIcons.arrowDownLeft; + } + } + } + + String categoryName = 'Unknown'; + if (categoriesState.hasValue) { + final match = categoriesState.value!.where((c) => c.id == t.categoryId); + if (match.isNotEmpty) categoryName = match.first.name; + } + + String accountName = 'Unknown'; + if (walletsState.hasValue) { + if (isExpense && t.toWalletId != null) { + final match = walletsState.value!.where((w) => w.id == t.toWalletId); + if (match.isNotEmpty) accountName = match.first.name; + } else if (isIncome && t.toWalletId != null) { + final match = walletsState.value!.where((w) => w.id == t.toWalletId); + if (match.isNotEmpty) accountName = match.first.name; + } else if (isTransfer && t.toWalletId != null) { + final match = walletsState.value!.where((w) => w.id == t.toWalletId); + if (match.isNotEmpty) accountName = 'To ${match.first.name}'; + } + } + + String fromName = ''; + if (t.fromWalletId != null && walletsState.hasValue) { + final match = walletsState.value!.where((w) => w.id == t.fromWalletId); + if (match.isNotEmpty) fromName = match.first.name; + } + + String toName = ''; + if (t.toWalletId != null && walletsState.hasValue) { + final match = walletsState.value!.where((w) => w.id == t.toWalletId); + if (match.isNotEmpty) toName = match.first.name; + } + + String subtitleText = ''; + if (categoryName == 'Unknown' && fromName.isNotEmpty && toName.isNotEmpty) { + subtitleText = '$fromName → $toName • ${DateFormat('MMM dd, yyyy').format(t.date)}'; + } else if (categoryName == 'Unknown' && toName.isNotEmpty) { + subtitleText = 'To $toName • ${DateFormat('MMM dd, yyyy').format(t.date)}'; + } else if (categoryName == 'Unknown' && fromName.isNotEmpty) { + subtitleText = 'From $fromName • ${DateFormat('MMM dd, yyyy').format(t.date)}'; + } else { + subtitleText = '$categoryName • ${DateFormat('MMM dd, yyyy').format(t.date)}${fromName.isNotEmpty ? ' • 💼 $fromName' : ''}'; + } + + return Dismissible( + key: Key(t.id.toString()), + direction: DismissDirection.endToStart, + background: Container( + color: Colors.red, + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 20), + child: const Icon(LucideIcons.trash2, color: Colors.white), + ), + confirmDismiss: (dir) async { + return await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Delete Transaction?'), + content: const Text('Are you sure you want to delete this transaction?'), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancel')), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Delete', style: TextStyle(color: Colors.red)) + ), + ], + ), + ); + }, + onDismissed: (dir) { + ref.read(transactionProvider.notifier).deleteTransaction(t.id); + ref.read(paginatedTransactionProvider.notifier).removeTransactionFromState(t.id); + }, + child: Card( + margin: const EdgeInsets.only(bottom: 12), + child: Column( + children: [ + ListTile( + onTap: () { + Navigator.push(context, MaterialPageRoute(builder: (_) => AddTransactionScreen(transaction: t))); + }, + leading: CircleAvatar( + backgroundColor: typeColor.withValues(alpha: 0.1), + child: Icon(typeIcon, color: typeColor), + ), + title: Text((t.description != null && t.description!.isNotEmpty) ? t.description! : accountName, style: const TextStyle(fontWeight: FontWeight.bold)), + subtitle: Text(subtitleText), + trailing: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '${isIncome ? '+' : (isTransfer ? '' : '-')}Rs. ${t.amount.toStringAsFixed(0)}', + style: TextStyle(fontWeight: FontWeight.bold, color: typeColor), + ), + if (t.investmentStatus == 'OPEN' && (typeColor == NatureColors.getColor('INVESTMENTS') || typeColor == NatureColors.getColor('RECEIVABLES'))) + GestureDetector( + onTap: () { + showDialog(context: context, builder: (_) => MaturityDialog(transaction: t)); + }, + child: const Padding( + padding: EdgeInsets.only(top: 4.0), + child: Text('Close', style: TextStyle(color: Colors.blue, fontWeight: FontWeight.bold, fontSize: 12)), + ), + ), + ], + ), + ), + ], + ), + ), + ); + }, + ), + ); + } + ), + ); + } +} diff --git a/kifi-app/lib/features/transactions/presentation/widgets/attachment_gallery_screen.dart b/kifi-app/lib/features/transactions/presentation/widgets/attachment_gallery_screen.dart new file mode 100644 index 0000000..a68e604 --- /dev/null +++ b/kifi-app/lib/features/transactions/presentation/widgets/attachment_gallery_screen.dart @@ -0,0 +1,138 @@ +import 'package:flutter/material.dart'; + +class AttachmentGalleryScreen extends StatefulWidget { + final List images; + final int initialIndex; + + const AttachmentGalleryScreen({ + super.key, + required this.images, + required this.initialIndex, + }); + + @override + State createState() => _AttachmentGalleryScreenState(); +} + +class _AttachmentGalleryScreenState extends State { + late PageController _pageController; + int _currentIndex = 0; + + @override + void initState() { + super.initState(); + _currentIndex = widget.initialIndex; + _pageController = PageController(initialPage: widget.initialIndex); + } + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + backgroundColor: Colors.black, + iconTheme: const IconThemeData(color: Colors.white), + title: Text( + '${_currentIndex + 1} / ${widget.images.length}', + style: const TextStyle(color: Colors.white), + ), + ), + body: PageView.builder( + controller: _pageController, + onPageChanged: (index) { + setState(() { + _currentIndex = index; + }); + }, + itemCount: widget.images.length, + itemBuilder: (context, index) { + return _ZoomableImage(image: widget.images[index]); + }, + ), + ); + } +} + +class _ZoomableImage extends StatefulWidget { + final ImageProvider image; + const _ZoomableImage({required this.image}); + + @override + State<_ZoomableImage> createState() => _ZoomableImageState(); +} + +class _ZoomableImageState extends State<_ZoomableImage> with SingleTickerProviderStateMixin { + final TransformationController _transformationController = TransformationController(); + late AnimationController _animationController; + Animation? _animation; + TapDownDetails? _doubleTapDetails; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 200), + )..addListener(() { + if (_animation != null) { + _transformationController.value = _animation!.value; + } + }); + } + + @override + void dispose() { + _animationController.dispose(); + _transformationController.dispose(); + super.dispose(); + } + + void _handleDoubleTap() { + if (_doubleTapDetails == null) return; + final position = _doubleTapDetails!.localPosition; + + final matrix = _transformationController.value; + final scale = matrix.getMaxScaleOnAxis(); + + Matrix4 endMatrix; + if (scale > 1.0) { + // Zoom out + endMatrix = Matrix4.identity(); + } else { + // Zoom in + endMatrix = Matrix4.identity() + ..translate(-position.dx * 1.5, -position.dy * 1.5) + ..scale(2.5); + } + + _animation = Matrix4Tween( + begin: _transformationController.value, + end: endMatrix, + ).animate(CurveTween(curve: Curves.easeInOut).animate(_animationController)); + + _animationController.forward(from: 0); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onDoubleTapDown: (d) => _doubleTapDetails = d, + onDoubleTap: _handleDoubleTap, + child: InteractiveViewer( + transformationController: _transformationController, + minScale: 1.0, + maxScale: 4.0, + child: Image( + image: widget.image, + fit: BoxFit.contain, + ), + ), + ); + } +} diff --git a/kifi-app/lib/features/transactions/presentation/widgets/transaction_filter_sheet.dart b/kifi-app/lib/features/transactions/presentation/widgets/transaction_filter_sheet.dart new file mode 100644 index 0000000..86f0a17 --- /dev/null +++ b/kifi-app/lib/features/transactions/presentation/widgets/transaction_filter_sheet.dart @@ -0,0 +1,173 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import '../../providers/providers.dart'; +import '../../providers/paginated_transaction_provider.dart'; + +class TransactionFilterSheet extends ConsumerStatefulWidget { + const TransactionFilterSheet({super.key}); + + @override + ConsumerState createState() => _TransactionFilterSheetState(); +} + +class _TransactionFilterSheetState extends ConsumerState { + String? _selectedType; + int? _selectedWalletId; + int? _selectedCategoryId; + + @override + void initState() { + super.initState(); + final currentState = ref.read(paginatedTransactionProvider); + _selectedType = currentState.type; + _selectedWalletId = currentState.walletId; + _selectedCategoryId = currentState.categoryId; + } + + void _applyFilters() { + ref.read(paginatedTransactionProvider.notifier).updateFilters( + type: _selectedType, + walletId: _selectedWalletId, + categoryId: _selectedCategoryId, + ); + Navigator.pop(context); + } + + void _resetFilters() { + ref.read(paginatedTransactionProvider.notifier).clearFilters(); + Navigator.pop(context); + } + + @override + Widget build(BuildContext context) { + final walletsState = ref.watch(walletProvider); + final categoriesState = ref.watch(categoryProvider); + + return Container( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: SafeArea( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Filter Transactions', style: Theme.of(context).textTheme.titleLarge), + IconButton( + icon: const Icon(LucideIcons.x), + onPressed: () => Navigator.pop(context), + ), + ], + ), + const SizedBox(height: 24), + + // Type Filter + Text('Transaction Type', style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: 8), + SegmentedButton( + segments: const [ + ButtonSegment(value: 'ALL', label: Text('All')), + ButtonSegment(value: 'EXPENSE', label: Text('Expense')), + ButtonSegment(value: 'INCOME', label: Text('Income')), + ], + selected: {_selectedType ?? 'ALL'}, + onSelectionChanged: (set) { + setState(() => _selectedType = set.first == 'ALL' ? null : set.first); + }, + ), + const SizedBox(height: 24), + + // Wallet Filter + Text('Wallet', style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: 8), + DropdownButtonFormField( + value: _selectedWalletId, + style: const TextStyle(fontWeight: FontWeight.w500, color: Colors.black87, fontSize: 16), + decoration: InputDecoration( + filled: true, + fillColor: Colors.grey.shade100, + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)), + ), + items: [ + const DropdownMenuItem(value: null, child: Text('All Wallets')), + if (walletsState.hasValue) + ...walletsState.value!.map((w) => DropdownMenuItem( + value: w.id, + child: Text(w.name), + )), + ], + onChanged: (val) => setState(() => _selectedWalletId = val), + ), + const SizedBox(height: 24), + + // Category Filter + Text('Category', style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: 8), + DropdownButtonFormField( + value: _selectedCategoryId, + style: const TextStyle(fontWeight: FontWeight.w500, color: Colors.black87, fontSize: 16), + decoration: InputDecoration( + filled: true, + fillColor: Colors.grey.shade100, + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)), + ), + items: [ + const DropdownMenuItem(value: null, child: Text('All Categories')), + if (categoriesState.hasValue) + ...categoriesState.value!.map((c) => DropdownMenuItem( + value: c.id, + child: Text(c.name), + )), + ], + onChanged: (val) => setState(() => _selectedCategoryId = val), + ), + const SizedBox(height: 32), + + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: _resetFilters, + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + foregroundColor: Colors.grey.shade700, + side: BorderSide(color: Colors.grey.shade300), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + ), + child: const Text('Reset', style: TextStyle(fontWeight: FontWeight.bold)), + ), + ), + const SizedBox(width: 16), + Expanded( + flex: 2, + child: ElevatedButton( + onPressed: _applyFilters, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + backgroundColor: const Color(0xFF6C63FF), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + elevation: 0, + ), + child: const Text('Apply Filters', style: TextStyle(fontWeight: FontWeight.bold)), + ), + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/kifi-app/lib/features/transactions/providers/paginated_transaction_provider.dart b/kifi-app/lib/features/transactions/providers/paginated_transaction_provider.dart new file mode 100644 index 0000000..fa0c91f --- /dev/null +++ b/kifi-app/lib/features/transactions/providers/paginated_transaction_provider.dart @@ -0,0 +1,115 @@ +import 'dart:async'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../data/repository.dart'; +import '../data/models.dart'; +import 'providers.dart'; + +class PaginatedTransactionState { + final List transactions; + final bool isLoading; + final bool hasMore; + final String? type; + final int? walletId; + final int? categoryId; + final String? search; + + PaginatedTransactionState({ + required this.transactions, + this.isLoading = false, + this.hasMore = true, + this.type, + this.walletId, + this.categoryId, + this.search, + }); + + PaginatedTransactionState copyWith({ + List? transactions, + bool? isLoading, + bool? hasMore, + String? type, + int? walletId, + int? categoryId, + String? search, + }) { + return PaginatedTransactionState( + transactions: transactions ?? this.transactions, + isLoading: isLoading ?? this.isLoading, + hasMore: hasMore ?? this.hasMore, + type: type != null ? (type == 'ALL' ? null : type) : this.type, + walletId: walletId != null ? (walletId == -1 ? null : walletId) : this.walletId, + categoryId: categoryId != null ? (categoryId == -1 ? null : categoryId) : this.categoryId, + search: search != null ? (search == '' ? null : search) : this.search, + ); + } +} + +class PaginatedTransactionNotifier extends Notifier { + int _currentPage = 0; + + @override + PaginatedTransactionState build() { + _currentPage = 0; + Future.microtask(() => loadMore()); + return PaginatedTransactionState(transactions: []); + } + + void updateFilters({String? type, int? walletId, int? categoryId, String? search}) { + state = state.copyWith( + type: type, + walletId: walletId, + categoryId: categoryId, + search: search, + transactions: [], // reset + hasMore: true, + ); + _currentPage = 0; + loadMore(); + } + + void clearFilters() { + state = PaginatedTransactionState(transactions: []); + _currentPage = 0; + loadMore(); + } + + Future loadMore() async { + if (state.isLoading || !state.hasMore) return; + + state = state.copyWith(isLoading: true); + try { + final repository = ref.read(apiRepositoryProvider); + final response = await repository.searchTransactions( + page: _currentPage, + size: 20, + type: state.type, + walletId: state.walletId, + categoryId: state.categoryId, + search: state.search, + ); + + final newContent = response['content'] as List; + final totalPages = response['totalPages'] as int; + + _currentPage++; + + state = state.copyWith( + transactions: [...state.transactions, ...newContent], + isLoading: false, + hasMore: _currentPage < totalPages, + ); + } catch (e) { + state = state.copyWith(isLoading: false); + print("Error loading paginated transactions: $e"); + } + } + + void removeTransactionFromState(int id) { + final updatedList = state.transactions.where((t) => t.id != id).toList(); + state = state.copyWith(transactions: updatedList); + } +} + +final paginatedTransactionProvider = NotifierProvider(() { + return PaginatedTransactionNotifier(); +}); diff --git a/kifi-app/lib/features/transactions/providers/providers.dart b/kifi-app/lib/features/transactions/providers/providers.dart new file mode 100644 index 0000000..5c28e79 --- /dev/null +++ b/kifi-app/lib/features/transactions/providers/providers.dart @@ -0,0 +1,221 @@ +import 'dart:async'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../data/repository.dart'; +import '../data/models.dart'; + +final apiRepositoryProvider = Provider((ref) => ApiRepository()); + +class CategoryNotifier extends AsyncNotifier> { + @override + FutureOr> build() { + return ref.watch(apiRepositoryProvider).getCategories(); + } + + Future addCategory(String name, String? iconName) async { + final newCategory = await ref.read(apiRepositoryProvider).addCategory(Category(id: 0, name: name, iconName: iconName)); + if (state.value != null) { + state = AsyncValue.data([...state.value!, newCategory]); + } + } +} + +final categoryProvider = AsyncNotifierProvider>(() => CategoryNotifier()); + + +class TransactionNotifier extends AsyncNotifier> { + @override + FutureOr> build() { + return ref.watch(apiRepositoryProvider).getTransactions(); + } + + Future addTransaction(Transaction transaction, {List>? base64Attachments}) async { + final newTransaction = await ref.read(apiRepositoryProvider).addTransaction(transaction); + + // Upload attachments if any + if (base64Attachments != null && base64Attachments.isNotEmpty) { + for (var attachment in base64Attachments) { + await ref.read(apiRepositoryProvider).addAttachment( + newTransaction.id, + attachment['fileName']!, + attachment['contentType']!, + attachment['base64Content']! + ); + } + } + + if (state.value != null) { + // Re-fetch transactions to get the fully populated transaction with attachments from backend + ref.invalidateSelf(); + } + } + + Future updateTransaction(Transaction transaction, {List>? base64Attachments, List? deletedAttachmentIds}) async { + final updatedTransaction = await ref.read(apiRepositoryProvider).updateTransaction(transaction); + + bool hasChanges = false; + if (base64Attachments != null && base64Attachments.isNotEmpty) { + for (var attachment in base64Attachments) { + await ref.read(apiRepositoryProvider).addAttachment( + updatedTransaction.id, + attachment['fileName']!, + attachment['contentType']!, + attachment['base64Content']! + ); + } + hasChanges = true; + } + + if (deletedAttachmentIds != null && deletedAttachmentIds.isNotEmpty) { + for (var id in deletedAttachmentIds) { + await ref.read(apiRepositoryProvider).deleteAttachment(id); + } + hasChanges = true; + } + + if (hasChanges) { + ref.invalidateSelf(); + } else { + if (state.value != null) { + final updatedList = state.value!.map((t) => t.id == updatedTransaction.id ? updatedTransaction : t).toList(); + state = AsyncValue.data(updatedList); + } + } + } + + Future deleteTransaction(int id) async { + await ref.read(apiRepositoryProvider).deleteTransaction(id); + if (state.value != null) { + state = AsyncValue.data(state.value!.where((t) => t.id != id).toList()); + } + } + + Future closeInvestment(int id, double maturityAmount, DateTime closingDate, int toWalletId) async { + final updatedTransaction = await ref.read(apiRepositoryProvider).closeInvestment(id, maturityAmount, closingDate, toWalletId); + if (state.value != null) { + final updatedList = state.value!.map((t) => t.id == updatedTransaction.id ? updatedTransaction : t).toList(); + state = AsyncValue.data(updatedList); + } + } +} + +final transactionProvider = AsyncNotifierProvider>(() => TransactionNotifier()); + +class BudgetNotifier extends AsyncNotifier> { + @override + FutureOr> build() { + return ref.watch(apiRepositoryProvider).getBudgets(); + } + + Future addOrUpdateBudget(Budget budget) async { + final updatedBudget = await ref.read(apiRepositoryProvider).addOrUpdateBudget(budget); + if (state.value != null) { + final list = List.from(state.value!); + final index = list.indexWhere((b) => + (b.categoryId != null && b.categoryId == budget.categoryId) || + (b.walletId != null && b.walletId == budget.walletId) + ); + if (index >= 0) { + list[index] = updatedBudget; + } else { + list.add(updatedBudget); + } + state = AsyncValue.data(list); + } + } +} + +final budgetProvider = AsyncNotifierProvider>(() => BudgetNotifier()); + +class RecurringTransactionNotifier extends AsyncNotifier> { + @override + FutureOr> build() { + return ref.watch(apiRepositoryProvider).getRecurringTransactions(); + } + + Future addRecurringTransaction(RecurringTransaction rt) async { + final newRt = await ref.read(apiRepositoryProvider).addRecurringTransaction(rt); + if (state.value != null) { + state = AsyncValue.data([newRt, ...state.value!]); + } + } +} + +final recurringTransactionProvider = AsyncNotifierProvider>(() => RecurringTransactionNotifier()); + +class WalletNotifier extends AsyncNotifier> { + @override + FutureOr> build() { + return ref.watch(apiRepositoryProvider).getWallets(); + } + + Future createWallet({ + required String name, + String nature = 'CASH', + double initialBalance = 0.0, + String currency = 'INR', + String? icon, + String? color, + }) async { + final newWallet = await ref.read(apiRepositoryProvider).createWallet( + name: name, + nature: nature, + initialBalance: initialBalance, + currency: currency, + icon: icon, + color: color, + ); + if (state.value != null) { + state = AsyncValue.data([...state.value!, newWallet]); + } + return newWallet; + } + + Future inviteUser(int walletId, String email) async { + await ref.read(apiRepositoryProvider).inviteUserToWallet(walletId, email); + } + + Future editWallet(int id, {String? name, String? nature, String? icon, String? color}) async { + final updated = await ref.read(apiRepositoryProvider).editWallet( + id: id, + name: name, + nature: nature, + icon: icon, + color: color, + ); + if (state.value != null) { + state = AsyncValue.data( + state.value!.map((w) => w.id == id ? updated : w).toList(), + ); + } + } + + Future deleteWallet(int id) async { + await ref.read(apiRepositoryProvider).deleteWallet(id); + if (state.value != null) { + state = AsyncValue.data( + state.value!.where((w) => w.id != id).toList(), + ); + } + } +} + +class InvitationNotifier extends AsyncNotifier> { + @override + Future> build() async { + return ref.read(apiRepositoryProvider).getInvitations(); + } + + Future acceptInvitation(int invitationId) async { + await ref.read(apiRepositoryProvider).acceptInvitation(invitationId); + state = AsyncValue.data(state.value?.where((inv) => inv.id != invitationId).toList() ?? []); + ref.invalidate(walletProvider); // Refresh wallets + } + + Future rejectInvitation(int invitationId) async { + await ref.read(apiRepositoryProvider).rejectInvitation(invitationId); + state = AsyncValue.data(state.value?.where((inv) => inv.id != invitationId).toList() ?? []); + } +} + +final walletProvider = AsyncNotifierProvider>(() => WalletNotifier()); +final invitationProvider = AsyncNotifierProvider>(() => InvitationNotifier()); diff --git a/kifi-app/lib/main.dart b/kifi-app/lib/main.dart new file mode 100644 index 0000000..492eebf --- /dev/null +++ b/kifi-app/lib/main.dart @@ -0,0 +1,122 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:local_auth/local_auth.dart'; +import 'core/theme/app_theme.dart'; +import 'core/theme/theme_provider.dart'; +import 'features/auth/presentation/auth_screen.dart'; +import 'features/dashboard/presentation/dashboard_screen.dart'; +import 'features/onboarding/presentation/onboarding_screen.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'core/network/dio_client.dart'; + +final GlobalKey navigatorKey = GlobalKey(); + +void main() { + WidgetsFlutterBinding.ensureInitialized(); + runApp( + const ProviderScope( + child: KifiApp(), + ), + ); +} + +class KifiApp extends ConsumerStatefulWidget { + const KifiApp({super.key}); + + @override + ConsumerState createState() => _KifiAppState(); +} + +class _KifiAppState extends ConsumerState { + bool _isLoading = true; + bool _isAuthenticated = false; + bool _hasSeenOnboarding = false; + final LocalAuthentication _localAuth = LocalAuthentication(); + String? _startupError; + + @override + void initState() { + super.initState(); + _checkAuthStatus(); + } + + Future _authenticateWithBiometrics() async { + try { + final canCheckBiometrics = await _localAuth.canCheckBiometrics; + final isDeviceSupported = await _localAuth.isDeviceSupported(); + + if (!canCheckBiometrics && !isDeviceSupported) { + return true; // Pass if device doesn't support biometrics to avoid locking users out + } + + return await _localAuth.authenticate( + localizedReason: 'Please authenticate to access Kifi', + biometricOnly: true, + persistAcrossBackgrounding: true, + ); + } catch (e) { + return false; // Fail safe + } + } + + Future _checkAuthStatus() async { + try { + const storage = FlutterSecureStorage(); + final token = await storage.read(key: 'jwt_token'); + final prefs = await SharedPreferences.getInstance(); + final hasSeenOnboarding = prefs.getBool('has_seen_onboarding') ?? false; + + if (token != null && token.isNotEmpty) { + // Prompt for biometrics if returning user + final authSuccess = await _authenticateWithBiometrics(); + if (!authSuccess) { + setState(() { + _isAuthenticated = false; + _hasSeenOnboarding = hasSeenOnboarding; + _isLoading = false; + }); + return; // User failed biometrics, leave them on login screen + } + + setState(() { + _isAuthenticated = true; + _hasSeenOnboarding = hasSeenOnboarding; + _isLoading = false; + }); + } else { + setState(() { + _isAuthenticated = false; + _hasSeenOnboarding = hasSeenOnboarding; + _isLoading = false; + }); + } + } catch (e, stacktrace) { + setState(() { + _startupError = e.toString(); + _isLoading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + final themeMode = ref.watch(themeProvider); + + return MaterialApp( + navigatorKey: navigatorKey, + title: 'Kifi', + debugShowCheckedModeBanner: false, + theme: AppTheme.lightTheme, + darkTheme: AppTheme.darkTheme, + themeMode: themeMode, + home: _startupError != null + ? Scaffold(body: Center(child: Padding(padding: const EdgeInsets.all(20), child: Text("Startup Error: $_startupError", style: const TextStyle(color: Colors.red))))) + : _isLoading + ? const Scaffold(body: Center(child: CircularProgressIndicator())) + : (!_hasSeenOnboarding + ? const OnboardingScreen() + : (_isAuthenticated ? const DashboardScreen() : const AuthScreen())), + ); + } +} diff --git a/kifi-app/linux/.gitignore b/kifi-app/linux/.gitignore new file mode 100644 index 0000000..d3896c9 --- /dev/null +++ b/kifi-app/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/kifi-app/linux/CMakeLists.txt b/kifi-app/linux/CMakeLists.txt new file mode 100644 index 0000000..682c3db --- /dev/null +++ b/kifi-app/linux/CMakeLists.txt @@ -0,0 +1,128 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "kifi_app") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.example.kifi_app") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/kifi-app/linux/flutter/CMakeLists.txt b/kifi-app/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000..d5bd016 --- /dev/null +++ b/kifi-app/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/kifi-app/linux/flutter/generated_plugin_registrant.cc b/kifi-app/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..3ccd551 --- /dev/null +++ b/kifi-app/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,23 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include +#include + +void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); + g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); + flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); +} diff --git a/kifi-app/linux/flutter/generated_plugin_registrant.h b/kifi-app/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..e0f0a47 --- /dev/null +++ b/kifi-app/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/kifi-app/linux/flutter/generated_plugins.cmake b/kifi-app/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000..fbedf4a --- /dev/null +++ b/kifi-app/linux/flutter/generated_plugins.cmake @@ -0,0 +1,27 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + file_selector_linux + flutter_secure_storage_linux + url_launcher_linux +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/kifi-app/linux/runner/CMakeLists.txt b/kifi-app/linux/runner/CMakeLists.txt new file mode 100644 index 0000000..e97dabc --- /dev/null +++ b/kifi-app/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/kifi-app/linux/runner/main.cc b/kifi-app/linux/runner/main.cc new file mode 100644 index 0000000..e7c5c54 --- /dev/null +++ b/kifi-app/linux/runner/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/kifi-app/linux/runner/my_application.cc b/kifi-app/linux/runner/my_application.cc new file mode 100644 index 0000000..ce55b5d --- /dev/null +++ b/kifi-app/linux/runner/my_application.cc @@ -0,0 +1,148 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Called when first Flutter frame received. +static void first_frame_cb(MyApplication* self, FlView* view) { + gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); +} + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "kifi_app"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "kifi_app"); + } + + gtk_window_set_default_size(window, 1280, 720); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments( + project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + GdkRGBA background_color; + // Background defaults to black, override it here if necessary, e.g. #00000000 + // for transparent. + gdk_rgba_parse(&background_color, "#000000"); + fl_view_set_background_color(view, &background_color); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + // Show the window when Flutter renders. + // Requires the view to be realized so we can start rendering. + g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), + self); + gtk_widget_realize(GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, + gchar*** arguments, + int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = + my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, "flags", + G_APPLICATION_NON_UNIQUE, nullptr)); +} diff --git a/kifi-app/linux/runner/my_application.h b/kifi-app/linux/runner/my_application.h new file mode 100644 index 0000000..db16367 --- /dev/null +++ b/kifi-app/linux/runner/my_application.h @@ -0,0 +1,21 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, + my_application, + MY, + APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/kifi-app/macos/.gitignore b/kifi-app/macos/.gitignore new file mode 100644 index 0000000..746adbb --- /dev/null +++ b/kifi-app/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/kifi-app/macos/Flutter/Flutter-Debug.xcconfig b/kifi-app/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..4b81f9b --- /dev/null +++ b/kifi-app/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/kifi-app/macos/Flutter/Flutter-Release.xcconfig b/kifi-app/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000..5caa9d1 --- /dev/null +++ b/kifi-app/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/kifi-app/macos/Flutter/GeneratedPluginRegistrant.swift b/kifi-app/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000..a5ddc0a --- /dev/null +++ b/kifi-app/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,24 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import file_selector_macos +import flutter_image_compress_macos +import flutter_local_notifications +import flutter_secure_storage_darwin +import local_auth_darwin +import share_plus +import shared_preferences_foundation + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + FlutterImageCompressMacosPlugin.register(with: registry.registrar(forPlugin: "FlutterImageCompressMacosPlugin")) + FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) + FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) + LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin")) + SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) +} diff --git a/kifi-app/macos/Podfile b/kifi-app/macos/Podfile new file mode 100644 index 0000000..ff5ddb3 --- /dev/null +++ b/kifi-app/macos/Podfile @@ -0,0 +1,42 @@ +platform :osx, '10.15' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/kifi-app/macos/Podfile.lock b/kifi-app/macos/Podfile.lock new file mode 100644 index 0000000..c147388 --- /dev/null +++ b/kifi-app/macos/Podfile.lock @@ -0,0 +1,61 @@ +PODS: + - file_selector_macos (0.0.1): + - FlutterMacOS + - flutter_image_compress_macos (1.0.0): + - FlutterMacOS + - flutter_local_notifications (0.0.1): + - FlutterMacOS + - flutter_secure_storage_darwin (10.0.0): + - Flutter + - FlutterMacOS + - FlutterMacOS (1.0.0) + - local_auth_darwin (0.0.1): + - Flutter + - FlutterMacOS + - share_plus (0.0.1): + - FlutterMacOS + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + +DEPENDENCIES: + - file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`) + - flutter_image_compress_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_image_compress_macos/macos`) + - flutter_local_notifications (from `Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos`) + - flutter_secure_storage_darwin (from `Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_darwin/darwin`) + - FlutterMacOS (from `Flutter/ephemeral`) + - local_auth_darwin (from `Flutter/ephemeral/.symlinks/plugins/local_auth_darwin/darwin`) + - share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`) + - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) + +EXTERNAL SOURCES: + file_selector_macos: + :path: Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos + flutter_image_compress_macos: + :path: Flutter/ephemeral/.symlinks/plugins/flutter_image_compress_macos/macos + flutter_local_notifications: + :path: Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos + flutter_secure_storage_darwin: + :path: Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_darwin/darwin + FlutterMacOS: + :path: Flutter/ephemeral + local_auth_darwin: + :path: Flutter/ephemeral/.symlinks/plugins/local_auth_darwin/darwin + share_plus: + :path: Flutter/ephemeral/.symlinks/plugins/share_plus/macos + shared_preferences_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin + +SPEC CHECKSUMS: + file_selector_macos: 9e9e068e90ebee155097d00e89ae91edb2374db7 + flutter_image_compress_macos: 525b2993bb7d77af94af6ccf1f5848c6a111de02 + flutter_local_notifications: 1fc7ffb10a83d6a2eeeeddb152d43f1944b0aad0 + flutter_secure_storage_darwin: 46e401699982ee74142909676535e0f6a7321e58 + FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 + local_auth_darwin: c3ee6cce0a8d56be34c8ccb66ba31f7f180aaebb + share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc + shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb + +PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009 + +COCOAPODS: 1.17.0 diff --git a/kifi-app/macos/Runner.xcodeproj/project.pbxproj b/kifi-app/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..1276bb3 --- /dev/null +++ b/kifi-app/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,801 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 13E6D52C1D4C7DCFFA42F4D5 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F0AD46F4CF08D8ABFA9389B7 /* Pods_RunnerTests.framework */; }; + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + D019B287D22B2C8451B0E7CC /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D55D63F0CCF6B67251ACB58F /* Pods_Runner.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 2BCE98CE5839AF75A04CE444 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* kifi_app.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = kifi_app.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 34CB04CE37E9F9B5EBAECDB0 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 4DE98C080236BCC68E8BEB72 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + 9EBB28CEEBC01F87DD9B5565 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + D55D63F0CCF6B67251ACB58F /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + D5F39AE8AAF8101E5D5F9A78 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + E3B1F981FA12914383F7B827 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + F0AD46F4CF08D8ABFA9389B7 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 13E6D52C1D4C7DCFFA42F4D5 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + D019B287D22B2C8451B0E7CC /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 14E3581C47422ABFFAEFB9FF /* Pods */ = { + isa = PBXGroup; + children = ( + 9EBB28CEEBC01F87DD9B5565 /* Pods-Runner.debug.xcconfig */, + 4DE98C080236BCC68E8BEB72 /* Pods-Runner.release.xcconfig */, + 34CB04CE37E9F9B5EBAECDB0 /* Pods-Runner.profile.xcconfig */, + E3B1F981FA12914383F7B827 /* Pods-RunnerTests.debug.xcconfig */, + 2BCE98CE5839AF75A04CE444 /* Pods-RunnerTests.release.xcconfig */, + D5F39AE8AAF8101E5D5F9A78 /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + 14E3581C47422ABFFAEFB9FF /* Pods */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* kifi_app.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + D55D63F0CCF6B67251ACB58F /* Pods_Runner.framework */, + F0AD46F4CF08D8ABFA9389B7 /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 4E1F3EE715BD8D5266BD5445 /* [CP] Check Pods Manifest.lock */, + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 4586E3D7DB52F0D22DFF7D16 /* [CP] Check Pods Manifest.lock */, + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + 437B54CF114302BCDA978CCC /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* kifi_app.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; + 437B54CF114302BCDA978CCC /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 4586E3D7DB52F0D22DFF7D16 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 4E1F3EE715BD8D5266BD5445 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = E3B1F981FA12914383F7B827 /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.kifiApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/kifi_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/kifi_app"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 2BCE98CE5839AF75A04CE444 /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.kifiApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/kifi_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/kifi_app"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = D5F39AE8AAF8101E5D5F9A78 /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.kifiApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/kifi_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/kifi_app"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/kifi-app/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/kifi-app/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/kifi-app/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/kifi-app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/kifi-app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..c7d9bf4 --- /dev/null +++ b/kifi-app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/kifi-app/macos/Runner.xcworkspace/contents.xcworkspacedata b/kifi-app/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..21a3cc1 --- /dev/null +++ b/kifi-app/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/kifi-app/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/kifi-app/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/kifi-app/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/kifi-app/macos/Runner/AppDelegate.swift b/kifi-app/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..b3c1761 --- /dev/null +++ b/kifi-app/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/kifi-app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/kifi-app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..a2ec33f --- /dev/null +++ b/kifi-app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/kifi-app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/kifi-app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000..82b6f9d Binary files /dev/null and b/kifi-app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/kifi-app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/kifi-app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000..13b35eb Binary files /dev/null and b/kifi-app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/kifi-app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/kifi-app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000..0a3f5fa Binary files /dev/null and b/kifi-app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/kifi-app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/kifi-app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000..bdb5722 Binary files /dev/null and b/kifi-app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/kifi-app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/kifi-app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000..f083318 Binary files /dev/null and b/kifi-app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/kifi-app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/kifi-app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000..326c0e7 Binary files /dev/null and b/kifi-app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/kifi-app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/kifi-app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000..2f1632c Binary files /dev/null and b/kifi-app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/kifi-app/macos/Runner/Base.lproj/MainMenu.xib b/kifi-app/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000..80e867a --- /dev/null +++ b/kifi-app/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/kifi-app/macos/Runner/Configs/AppInfo.xcconfig b/kifi-app/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..8ce0c62 --- /dev/null +++ b/kifi-app/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = kifi_app + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.example.kifiApp + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2026 com.example. All rights reserved. diff --git a/kifi-app/macos/Runner/Configs/Debug.xcconfig b/kifi-app/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..36b0fd9 --- /dev/null +++ b/kifi-app/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/kifi-app/macos/Runner/Configs/Release.xcconfig b/kifi-app/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..dff4f49 --- /dev/null +++ b/kifi-app/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/kifi-app/macos/Runner/Configs/Warnings.xcconfig b/kifi-app/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..42bcbf4 --- /dev/null +++ b/kifi-app/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/kifi-app/macos/Runner/DebugProfile.entitlements b/kifi-app/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..dddb8a3 --- /dev/null +++ b/kifi-app/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + diff --git a/kifi-app/macos/Runner/Info.plist b/kifi-app/macos/Runner/Info.plist new file mode 100644 index 0000000..4789daa --- /dev/null +++ b/kifi-app/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/kifi-app/macos/Runner/MainFlutterWindow.swift b/kifi-app/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..3cc05eb --- /dev/null +++ b/kifi-app/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/kifi-app/macos/Runner/Release.entitlements b/kifi-app/macos/Runner/Release.entitlements new file mode 100644 index 0000000..852fa1a --- /dev/null +++ b/kifi-app/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/kifi-app/macos/RunnerTests/RunnerTests.swift b/kifi-app/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..61f3bd1 --- /dev/null +++ b/kifi-app/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/kifi-app/pubspec.lock b/kifi-app/pubspec.lock new file mode 100644 index 0000000..fe3288f --- /dev/null +++ b/kifi-app/pubspec.lock @@ -0,0 +1,1282 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: c209688d9f5a5f26b2fb47a188131a6fb9e876ae9e47af3737c0b4f58a93470d + url: "https://pub.dev" + source: hosted + version: "91.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: f51c8499b35f9b26820cfe914828a6a98a94efd5cc78b37bb7d03debae3a1d08 + url: "https://pub.dev" + source: hosted + version: "8.4.1" + archive: + dependency: transitive + description: + name: archive + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + url: "https://pub.dev" + source: hosted + version: "4.0.9" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + asn1lib: + dependency: transitive + description: + name: asn1lib + sha256: "9a8f69025044eb466b9b60ef3bc3ac99b4dc6c158ae9c56d25eeccf5bc56d024" + url: "https://pub.dev" + source: hosted + version: "1.6.5" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "956a3de0725ca232ad353565a8290d3357592bf4250f6f298a185e2d949c5d3d" + url: "https://pub.dev" + source: hosted + version: "1.15.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "92c9c43c383bfa1c32079d3bc492d55d6d4318044b7b47edaff8971cbb555c51" + url: "https://pub.dev" + source: hosted + version: "0.3.5+4" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.dev" + source: hosted + version: "1.0.9" + dbus: + dependency: transitive + description: + name: dbus + sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645" + url: "https://pub.dev" + source: hosted + version: "0.7.14" + dio: + dependency: "direct main" + description: + name: dio + sha256: "0df44ebba85e503958eb75d07eedd3c86275a58c1d3eda2f2ce8f0a2c3abbb3c" + url: "https://pub.dev" + source: hosted + version: "5.11.0" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "0786d0b7295a373de356fc0af4f6f1d0ab2844ed31b19dfc5e7556b70e24212c" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + encrypt: + dependency: "direct main" + description: + name: encrypt + sha256: "62d9aa4670cc2a8798bab89b39fc71b6dfbacf615de6cf5001fb39f7e4a996a2" + url: "https://pub.dev" + source: hosted + version: "5.0.3" + equatable: + dependency: transitive + description: + name: equatable + sha256: "3bce007a596ff8b3119c45d68aaef631272537c03d30e5d4534dd24bf4c5eaa2" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + ffi_leak_tracker: + dependency: transitive + description: + name: ffi_leak_tracker + sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" + url: "https://pub.dev" + source: hosted + version: "0.1.2" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + fl_chart: + dependency: "direct main" + description: + name: fl_chart + sha256: b938f77d042cbcd822936a7a359a7235bad8bd72070de1f827efc2cc297ac888 + url: "https://pub.dev" + source: hosted + version: "1.2.0" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_image_compress: + dependency: "direct main" + description: + name: flutter_image_compress + sha256: "98a48c05a7add6869c6838270e862124a4d571f9dd5d0cf209ed03a71b20ea84" + url: "https://pub.dev" + source: hosted + version: "2.5.1" + flutter_image_compress_common: + dependency: transitive + description: + name: flutter_image_compress_common + sha256: "76869e4d5f3d65f3431e7edff0b2d8ad1eea68b49c6a37772fdb1ae6016da9ed" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter_image_compress_macos: + dependency: transitive + description: + name: flutter_image_compress_macos + sha256: "0d2a842d2e544828fb32bda16dcfccb3149107df568898a4936d78232e486847" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + flutter_image_compress_ohos: + dependency: transitive + description: + name: flutter_image_compress_ohos + sha256: "1491bb7bcfdf59e3b127c263c116cfbf8ed3afade6e7fa691d9622e838ed7e48" + url: "https://pub.dev" + source: hosted + version: "0.0.3+1" + flutter_image_compress_platform_interface: + dependency: transitive + description: + name: flutter_image_compress_platform_interface + sha256: bbefb7967bda565004fdabdb3300dcbfb40c7ef8402675d23206e5bddc358178 + url: "https://pub.dev" + source: hosted + version: "1.1.0" + flutter_image_compress_web: + dependency: transitive + description: + name: flutter_image_compress_web + sha256: "91cc58e091a1e09c7683d216d579e2b964119a8527fc43b64dfdb00f1acc94ec" + url: "https://pub.dev" + source: hosted + version: "0.1.5+1" + flutter_launcher_icons: + dependency: "direct dev" + description: + name: flutter_launcher_icons + sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7" + url: "https://pub.dev" + source: hosted + version: "0.14.4" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_local_notifications: + dependency: "direct main" + description: + name: flutter_local_notifications + sha256: "1447ba911c60f2ba3f25dae1af151ec187162566b0f57e37771bf0b400f013ad" + url: "https://pub.dev" + source: hosted + version: "22.3.0" + flutter_local_notifications_linux: + dependency: transitive + description: + name: flutter_local_notifications_linux + sha256: "9ca97e63776f29ab1b955725c09999fc2c150523269db150c39274f2a43c5a8b" + url: "https://pub.dev" + source: hosted + version: "8.0.1" + flutter_local_notifications_platform_interface: + dependency: transitive + description: + name: flutter_local_notifications_platform_interface + sha256: "43c3761d916c9bd3d5c7ebbc44d82f4990329840c0c5d62ad5260cc1b5d399bd" + url: "https://pub.dev" + source: hosted + version: "12.2.0" + flutter_local_notifications_web: + dependency: transitive + description: + name: flutter_local_notifications_web + sha256: "516afaf97a2d1e67a036c6617321b00d205d72f7a67b6eccf936cd565f985878" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + flutter_local_notifications_windows: + dependency: transitive + description: + name: flutter_local_notifications_windows + sha256: "6f43bdd03b171b7a90f22647506fea33e2bb12294b7c7c7a3d690e960a382945" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" + url: "https://pub.dev" + source: hosted + version: "2.0.35" + flutter_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + sha256: "9255e1e3ad6e38906a1b4f8287678f95f378744c5b46b1985588543f3f19046e" + url: "https://pub.dev" + source: hosted + version: "3.3.2" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "15e8c8fe269fdf7d469b23008ab3df521c8b826ed345820532364c31bdebace6" + url: "https://pub.dev" + source: hosted + version: "11.0.0" + flutter_secure_storage_darwin: + dependency: transitive + description: + name: flutter_secure_storage_darwin + sha256: ac6d76a752de0cd738334eb4b21743fc4943f449f5b6e308f18838b048c02ac0 + url: "https://pub.dev" + source: hosted + version: "0.4.0" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: "76fa9c841b3b1619fc5b5bc36efc7d158fa2356f223b6caeb1d0c80a54168546" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: "788060052712555182aba55ecb5f8b6e5cb9cfe8f776c83249a61fe3ce877db4" + url: "https://pub.dev" + source: hosted + version: "2.0.3" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1" + url: "https://pub.dev" + source: hosted + version: "4.2.2" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + google_fonts: + dependency: "direct main" + description: + name: google_fonts + sha256: e3cb3ee6b47fd2472c23de6da5744796a4da195137759ddb3fbcc9467b7b3c7d + url: "https://pub.dev" + source: hosted + version: "8.2.1" + google_mlkit_commons: + dependency: transitive + description: + name: google_mlkit_commons + sha256: c7fc384bdb66c3b83e24e3a41818e61d7a378d900f65f5e775344fbca95a0917 + url: "https://pub.dev" + source: hosted + version: "0.12.0" + google_mlkit_text_recognition: + dependency: "direct main" + description: + name: google_mlkit_text_recognition + sha256: "8324fdc2628ebeb63568ad06437b984b716d938d87e4ce63ab24dfd3eeebf08a" + url: "https://pub.dev" + source: hosted + version: "0.16.0" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce + url: "https://pub.dev" + source: hosted + version: "4.8.0" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: d8402284df184bc05f4a2210c6c23983b0720f4cd87cbd05c5390a78af602667 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: d5b3e1774af29c9ab00103afb0d4614070f924d2e0057ac867ec98800114793f + url: "https://pub.dev" + source: hosted + version: "0.8.13+17" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588 + url: "https://pub.dev" + source: hosted + version: "0.8.13+6" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" + intl: + dependency: "direct main" + description: + name: intl + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" + url: "https://pub.dev" + source: hosted + version: "0.20.3" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + jni: + dependency: transitive + description: + name: jni + sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "7b717011ea40d04fd47c2731d3d1d36eb99eba3435c2753d62489e8c3c9991d5" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" + source: hosted + version: "0.7.2" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" + url: "https://pub.dev" + source: hosted + version: "4.12.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + local_auth: + dependency: "direct main" + description: + name: local_auth + sha256: ecf24edf2283c509ecd217e3595f6f71034b68888d28ad1dae6bfa0857b816ac + url: "https://pub.dev" + source: hosted + version: "3.0.2" + local_auth_android: + dependency: transitive + description: + name: local_auth_android + sha256: b201c006fa769c23386f89aa6837ec0eb8179fcfb212eadcf87b422b3f9a6a78 + url: "https://pub.dev" + source: hosted + version: "2.0.8" + local_auth_darwin: + dependency: transitive + description: + name: local_auth_darwin + sha256: a8c3d4e17454111f7fd31ff72a31222359f6059f7fe956c2dcfe0f88f49826d4 + url: "https://pub.dev" + source: hosted + version: "2.0.3" + local_auth_platform_interface: + dependency: transitive + description: + name: local_auth_platform_interface + sha256: f98b8e388588583d3f781f6806e4f4c9f9e189d898d27f0c249b93a1973dd122 + url: "https://pub.dev" + source: hosted + version: "1.1.0" + local_auth_windows: + dependency: transitive + description: + name: local_auth_windows + sha256: be12c5b8ba5e64896983123655c5f67d2484ecfcc95e367952ad6e3bff94cb16 + url: "https://pub.dev" + source: hosted + version: "2.0.1" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + lucide_icons: + dependency: "direct main" + description: + name: lucide_icons + sha256: ad24d0fd65707e48add30bebada7d90bff2a1bba0a72d6e9b19d44246b0e83c4 + url: "https://pub.dev" + source: hosted + version: "0.257.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e + url: "https://pub.dev" + source: hosted + version: "9.5.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.dev" + source: hosted + version: "2.1.6" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pointycastle: + dependency: "direct main" + description: + name: pointycastle + sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe" + url: "https://pub.dev" + source: hosted + version: "3.9.1" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + posix: + dependency: transitive + description: + name: posix + sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e + url: "https://pub.dev" + source: hosted + version: "6.5.2" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + riverpod: + dependency: transitive + description: + name: riverpod + sha256: "17100416c51db7810c71a7bb2c34d1f881faa0074fd452afb0c4db6f8f126c76" + url: "https://pub.dev" + source: hosted + version: "3.3.2" + share_plus: + dependency: "direct main" + description: + name: share_plus + sha256: "34f00f9becd2743c1fb05363d624f9f70d37f7ccdcdda47450bc0b8c9d327b8c" + url: "https://pub.dev" + source: hosted + version: "13.3.0" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: "365ef7379fc22507256adda3385152942ffce08935452bc972c2e52a0bebae41" + url: "https://pub.dev" + source: hosted + version: "7.2.0" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.dev" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53 + url: "https://pub.dev" + source: hosted + version: "2.4.23" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + shimmer: + dependency: "direct main" + description: + name: shimmer + sha256: "5f88c883a22e9f9f299e5ba0e4f7e6054857224976a5d9f839d4ebdc94a14ac9" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + url: "https://pub.dev" + source: hosted + version: "0.10.13" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://pub.dev" + source: hosted + version: "1.0.0" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test: + dependency: transitive + description: + name: test + sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" + url: "https://pub.dev" + source: hosted + version: "1.26.3" + test_api: + dependency: transitive + description: + name: test_api + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + url: "https://pub.dev" + source: hosted + version: "0.7.7" + test_core: + dependency: transitive + description: + name: test_core + sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" + url: "https://pub.dev" + source: hosted + version: "0.6.12" + timezone: + dependency: "direct main" + description: + name: timezone + sha256: "981d1020d6ef8fe1e7b3de5054e5b25579ae7c403d7734adc508ffc47668e9cb" + url: "https://pub.dev" + source: hosted + version: "0.11.1" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" + uuid: + dependency: transitive + description: + name: uuid + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.dev" + source: hosted + version: "4.6.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + url: "https://pub.dev" + source: hosted + version: "15.2.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + win32: + dependency: transitive + description: + name: win32 + sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d + url: "https://pub.dev" + source: hosted + version: "6.4.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.10.7 <4.0.0" + flutter: ">=3.38.4" diff --git a/kifi-app/pubspec.yaml b/kifi-app/pubspec.yaml new file mode 100644 index 0000000..550d382 --- /dev/null +++ b/kifi-app/pubspec.yaml @@ -0,0 +1,109 @@ +name: kifi_app +description: "A new Flutter project." +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: ^3.10.7 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + flutter_riverpod: ^3.3.2 + dio: ^5.11.0 + flutter_secure_storage: ^11.0.0 + fl_chart: ^1.2.0 + google_fonts: ^8.2.1 + intl: ^0.20.3 + lucide_icons: ^0.257.0 + local_auth: ^3.0.2 + path_provider: ^2.1.6 + share_plus: ^13.3.0 + image_picker: ^1.2.3 + flutter_image_compress: ^2.3.0 + google_mlkit_text_recognition: ^0.16.0 + shared_preferences: ^2.5.5 + shimmer: ^3.0.0 + flutter_local_notifications: ^22.3.0 + timezone: ^0.11.1 + encrypt: ^5.0.3 + pointycastle: ^3.9.1 + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^6.0.0 + flutter_launcher_icons: ^0.14.4 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/kifi-app/web/favicon.png b/kifi-app/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/kifi-app/web/favicon.png differ diff --git a/kifi-app/web/icons/Icon-192.png b/kifi-app/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/kifi-app/web/icons/Icon-192.png differ diff --git a/kifi-app/web/icons/Icon-512.png b/kifi-app/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/kifi-app/web/icons/Icon-512.png differ diff --git a/kifi-app/web/icons/Icon-maskable-192.png b/kifi-app/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/kifi-app/web/icons/Icon-maskable-192.png differ diff --git a/kifi-app/web/icons/Icon-maskable-512.png b/kifi-app/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/kifi-app/web/icons/Icon-maskable-512.png differ diff --git a/kifi-app/web/index.html b/kifi-app/web/index.html new file mode 100644 index 0000000..3ba871a --- /dev/null +++ b/kifi-app/web/index.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + kifi_app + + + + + + diff --git a/kifi-app/web/manifest.json b/kifi-app/web/manifest.json new file mode 100644 index 0000000..264f6bf --- /dev/null +++ b/kifi-app/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "kifi_app", + "short_name": "kifi_app", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/kifi-app/windows/.gitignore b/kifi-app/windows/.gitignore new file mode 100644 index 0000000..d492d0d --- /dev/null +++ b/kifi-app/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/kifi-app/windows/CMakeLists.txt b/kifi-app/windows/CMakeLists.txt new file mode 100644 index 0000000..630698e --- /dev/null +++ b/kifi-app/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(kifi_app LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "kifi_app") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/kifi-app/windows/flutter/CMakeLists.txt b/kifi-app/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000..903f489 --- /dev/null +++ b/kifi-app/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/kifi-app/windows/flutter/generated_plugin_registrant.cc b/kifi-app/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..24a84f1 --- /dev/null +++ b/kifi-app/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,26 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include +#include +#include +#include + +void RegisterPlugins(flutter::PluginRegistry* registry) { + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); + FlutterSecureStorageWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); + LocalAuthPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("LocalAuthPlugin")); + SharePlusWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); +} diff --git a/kifi-app/windows/flutter/generated_plugin_registrant.h b/kifi-app/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..dc139d8 --- /dev/null +++ b/kifi-app/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/kifi-app/windows/flutter/generated_plugins.cmake b/kifi-app/windows/flutter/generated_plugins.cmake new file mode 100644 index 0000000..845f504 --- /dev/null +++ b/kifi-app/windows/flutter/generated_plugins.cmake @@ -0,0 +1,30 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + file_selector_windows + flutter_secure_storage_windows + local_auth_windows + share_plus + url_launcher_windows +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST + flutter_local_notifications_windows + jni +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/kifi-app/windows/runner/CMakeLists.txt b/kifi-app/windows/runner/CMakeLists.txt new file mode 100644 index 0000000..394917c --- /dev/null +++ b/kifi-app/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/kifi-app/windows/runner/Runner.rc b/kifi-app/windows/runner/Runner.rc new file mode 100644 index 0000000..6c8889a --- /dev/null +++ b/kifi-app/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.example" "\0" + VALUE "FileDescription", "kifi_app" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "kifi_app" "\0" + VALUE "LegalCopyright", "Copyright (C) 2026 com.example. All rights reserved." "\0" + VALUE "OriginalFilename", "kifi_app.exe" "\0" + VALUE "ProductName", "kifi_app" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/kifi-app/windows/runner/flutter_window.cpp b/kifi-app/windows/runner/flutter_window.cpp new file mode 100644 index 0000000..955ee30 --- /dev/null +++ b/kifi-app/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/kifi-app/windows/runner/flutter_window.h b/kifi-app/windows/runner/flutter_window.h new file mode 100644 index 0000000..6da0652 --- /dev/null +++ b/kifi-app/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/kifi-app/windows/runner/main.cpp b/kifi-app/windows/runner/main.cpp new file mode 100644 index 0000000..1cef646 --- /dev/null +++ b/kifi-app/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"kifi_app", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/kifi-app/windows/runner/resource.h b/kifi-app/windows/runner/resource.h new file mode 100644 index 0000000..66a65d1 --- /dev/null +++ b/kifi-app/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/kifi-app/windows/runner/resources/app_icon.ico b/kifi-app/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000..c04e20c Binary files /dev/null and b/kifi-app/windows/runner/resources/app_icon.ico differ diff --git a/kifi-app/windows/runner/runner.exe.manifest b/kifi-app/windows/runner/runner.exe.manifest new file mode 100644 index 0000000..153653e --- /dev/null +++ b/kifi-app/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/kifi-app/windows/runner/utils.cpp b/kifi-app/windows/runner/utils.cpp new file mode 100644 index 0000000..3a0b465 --- /dev/null +++ b/kifi-app/windows/runner/utils.cpp @@ -0,0 +1,65 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + unsigned int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, nullptr, 0, nullptr, nullptr) + -1; // remove the trailing null character + int input_length = (int)wcslen(utf16_string); + std::string utf8_string; + if (target_length == 0 || target_length > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/kifi-app/windows/runner/utils.h b/kifi-app/windows/runner/utils.h new file mode 100644 index 0000000..3879d54 --- /dev/null +++ b/kifi-app/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/kifi-app/windows/runner/win32_window.cpp b/kifi-app/windows/runner/win32_window.cpp new file mode 100644 index 0000000..60608d0 --- /dev/null +++ b/kifi-app/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/kifi-app/windows/runner/win32_window.h b/kifi-app/windows/runner/win32_window.h new file mode 100644 index 0000000..e901dde --- /dev/null +++ b/kifi-app/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_ diff --git a/prompt.md b/prompt.md new file mode 100644 index 0000000..59620b1 --- /dev/null +++ b/prompt.md @@ -0,0 +1,1070 @@ +You are a senior software architect, Flutter/mobile engineer, and Spring Boot/Java backend engineer with 10+ years of production experience. + +You are working on an EXISTING application repository. + +Your job is to transform the existing application into a modern, platform-independent PERSONAL FINANCE MANAGEMENT application while preserving and reusing as much existing functionality as possible. + +IMPORTANT: +Do NOT rebuild the application from scratch. +Do NOT replace working functionality simply because you prefer another architecture. +Do NOT introduce a new framework/state-management/library without first checking what the project already uses. +Do NOT make destructive database changes. +Do NOT remove existing features unless explicitly required. + +============================================================ +PRIMARY PRODUCT GOAL +============================================================ + +Transform the existing application into a personal finance/accounting management app for ordinary users. + +The user should be able to: + +- track income +- track expenses +- manage cash +- manage bank accounts +- manage savings +- manage investments +- track loans +- track money lent to others +- transfer money between wallets +- create budgets +- analyze spending +- view financial history +- attach receipts/documents/images +- share wallets with other users + +The application must be understandable to users who know nothing about accounting. + +Avoid accounting terminology in the UI where possible. + +Use friendly terminology such as: + +"Wallet" +"Money In" +"Money Out" +"Transfer" +"Savings" +"Investment" +"Money Lent" +"Loan" +"Balance" + +Internal technical/accounting terminology may remain different if necessary. + +============================================================ +PHASE 0 — INSPECT BEFORE CODING +============================================================ + +FIRST inspect the entire repository. + +Do NOT start making large changes immediately. + +Understand: + +1. Flutter architecture +2. Flutter state management +3. Flutter navigation +4. Existing screens +5. Existing widgets/components +6. Existing models +7. Existing API layer +8. Existing authentication +9. Existing transaction workflow +10. Existing account/wallet implementation +11. Existing category implementation +12. Existing items implementation +13. Existing attachment/image upload implementation +14. Existing wallet-sharing implementation +15. Spring Boot architecture +16. Controllers +17. Services +18. Repositories +19. DTOs +20. Entities +21. Database schema +22. Database migrations +23. Configuration +24. Security/authorization +25. Existing tests + +Search the repository instead of guessing. + +Identify what can be reused. + +Identify broken functionality. + +Identify technical debt that directly affects this transformation. + +Then produce a concise report: + +- Current architecture +- Current application workflow +- Current data model +- Current transaction model +- Existing reusable functionality +- Existing broken functionality +- Attachment implementation +- Wallet sharing implementation +- Risks +- Recommended implementation order + +Only after this analysis begin implementation. + +============================================================ +PRODUCT REQUIREMENT +============================================================ + +The client wants to change the nature of the application. + +Current application workflow is too limited. + +New application: + +PERSONAL FINANCE MANAGEMENT + +============================================================ +1. WALLETS +============================================================ + +Use "Wallet" as the primary user-facing concept instead of "Account" if this fits the existing architecture. + +A wallet represents a place/type where money is tracked. + +Examples: + +Cash +Bank Account +Credit Card +Savings +Investment +Loan +Money Lent +Salary +Other + +Users can create custom wallets. + +However, the system must control the underlying financial nature/type. + +Separate: + +USER-FACING WALLET +from +SYSTEM FINANCIAL NATURE + +Suggested internal nature values: + +EXPENSE +INCOME +CASH +DEPOSIT +SAVINGS +INVESTMENT +LOAN +LEND +TRANSFER + +You may improve this model if repository analysis indicates a better normalized design. + +The user should not be able to create arbitrary financial natures. + +Each wallet should support where applicable: + +- id +- name +- icon +- customizable icon +- color/theme +- nature/type +- opening balance +- current balance +- currency +- description +- active/archived +- createdAt +- updatedAt + +Prefer ARCHIVE over DELETE when transactions already reference a wallet. + +Do not destroy historical financial data. + +============================================================ +2. TRANSACTIONS +============================================================ + +The primary "+" action should be extremely simple. + +When the user taps "+": + +Step 1: +Amount + +Step 2: +From Wallet + +Step 3: +To Wallet + +Then: + +- Category +- Description +- Items +- Attachments +- Date/time +- Notes + +Negative transaction amounts must be supported. + +Examples: + +Expense: +Bank → Food/Expense + +Income: +Income → Bank + +Transfer: +Bank → Cash + +Savings: +Bank → Savings + +Investment: +Bank → Investment + +Money lent: +Bank → Money Lent + +Loan received: +Loan → Bank + +The UI should explain From/To in simple language. + +Example: + +"From Wallet" +Where the money comes from. + +"To Wallet" +Where the money goes. + +Do not force users to understand debit/credit terminology. + +============================================================ +3. TRANSACTION EDITING +============================================================ + +Users must be able to edit: + +- amount +- from wallet +- to wallet +- category +- description +- items +- attachments +- date/time +- notes + +After editing: + +- balances must be recalculated correctly +- reports must remain correct +- dashboard totals must remain correct +- ledger must remain correct + +Support: + +- edit +- delete/cancel +- attachment add/remove/replace +- item add/edit/remove + +Use confirmation for destructive operations. + +============================================================ +4. ATTACHMENTS +============================================================ + +The existing image upload is currently not working. + +FIX THE ROOT CAUSE. + +Inspect the entire pipeline: + +Flutter +→ request/multipart +→ Spring Boot +→ storage +→ database/reference +→ retrieval +→ Flutter display + +Do not hide the error. + +Support where practical: + +- camera +- gallery +- file picker +- image preview +- multiple attachments +- upload progress +- retry +- delete +- replace +- error state + +Preserve existing attachment data. + +============================================================ +5. WALLET SHARING +============================================================ + +Existing wallet-sharing functionality MUST remain. + +Do not remove it. + +Inspect the existing implementation first. + +Preserve existing behavior. + +Improve UX if useful. + +Potential permissions: + +OWNER +VIEW +EDIT + +Authorization must be enforced on the backend. + +Never rely only on Flutter UI restrictions. + +============================================================ +6. DASHBOARD +============================================================ + +Build a rich financial dashboard. + +Include: + +- total balance / net position +- income +- expenses +- savings +- investments +- loans +- money lent +- cash +- recent transactions +- budget status +- spending trends +- wallet summaries + +Provide nature-based tabs/cards such as: + +All +Cash +Spending +Savings +Investments +Loans +Money Lent + +Each card should show: + +- current balance +- trend +- relevant summary +- icon +- visual indicator + +Cards must be tappable. + +Tap a card → detailed ledger. + +============================================================ +7. LEDGER +============================================================ + +Dashboard cards and wallets should open a ledger-style transaction view. + +Conceptually show: + +Date | Amount | From | To | Balance + +On mobile use responsive cards/list rows rather than literal desktop tables. + +Each transaction should show: + +- date/time +- amount +- from wallet +- to wallet +- category +- description +- attachment indicator +- item count +- running balance + +Support: + +- search +- date range +- wallet filter +- nature filter +- category filter +- amount range +- income/expense filter +- attachment filter +- sorting +- ascending/descending +- pagination/infinite scrolling + +============================================================ +8. CATEGORIES +============================================================ + +Preserve existing categories. + +Improve where appropriate. + +Examples: + +Food +Transport +Shopping +Bills +Health +Education +Entertainment +Salary +Freelance +Investment +Loan +Other + +Allow user management where safe: + +- create +- rename +- archive +- icon +- color + +Do not break existing category records. + +============================================================ +9. ITEMS +============================================================ + +Preserve existing transaction item functionality. + +Example: + +Grocery ₹1,250 + +Rice ₹500 +Vegetables ₹300 +Snacks ₹250 +Other ₹200 + +Display: + +- item name +- quantity if supported +- price +- total + +Transaction total should be validated against item totals where appropriate. + +============================================================ +10. BUDGETING +============================================================ + +Add budgeting functionality. + +Support: + +- monthly budgets +- category budgets +- wallet-specific budgets + +Show: + +Allocated +Spent +Remaining +Percentage used + +Provide warning levels. + +Examples: + +70% → informational +90% → warning +100%+ → exceeded + +Make thresholds configurable if appropriate. + +============================================================ +11. REPORTS / ANALYTICS +============================================================ + +Provide understandable financial analytics. + +Charts: + +- income vs expenses +- spending by category +- cash flow +- savings trend +- investment trend +- wallet distribution +- top spending categories +- monthly comparison +- recurring expenses + +Charts must be mobile friendly. + +Avoid excessive complexity. + +============================================================ +12. SEARCH / FILTER +============================================================ + +Provide powerful transaction filtering. + +Filters: + +- date +- wallet +- category +- nature +- amount +- income/expense +- attachment +- item +- text search + +Make filtering easy for non-technical users. + +============================================================ +13. RECURRING TRANSACTIONS +============================================================ + +If compatible with the existing architecture, support: + +- salary +- rent +- EMI +- subscriptions +- insurance +- SIP +- recurring bills + +Fields: + +- frequency +- start date +- end date +- next occurrence +- active/inactive + +Do not over-engineer this if it conflicts heavily with the current architecture. + +============================================================ +14. ALERTS / NOTIFICATIONS +============================================================ + +Useful alerts: + +- budget nearing limit +- budget exceeded +- upcoming recurring payment +- loan due +- lending due +- unusual spending +- failed upload +- wallet activity + +Allow notification preferences. + +============================================================ +15. USER EDUCATION +============================================================ + +The application must teach users how to use it. + +Provide: + +- onboarding +- contextual hints +- tooltips +- "What is this?" explanations +- guided tours +- contextual help +- examples + +Example: + +"From Wallet" +Where the money comes from. + +"To Wallet" +Where the money goes. + +"Nature" +How the app categorizes this money movement. + +Users should be able to: + +- skip +- replay +- disable hints + +Do not make onboarding annoying. + +============================================================ +16. UI / UX +============================================================ + +Create a polished modern financial application. + +Requirements: + +- modern Flutter UI +- responsive design +- light theme +- dark theme +- customizable icons +- meaningful icons +- smooth animations +- animated dashboard cards +- page transitions +- bottom sheets +- dialogs +- snackbars +- loading states +- skeleton loaders +- empty states +- error states +- confirmation dialogs +- pull-to-refresh +- swipe actions where appropriate +- floating Add button + +Suggested navigation: + +Dashboard +Wallets +Transactions +Budgets +Reports +Profile + +BUT: + +Do not force this navigation if the existing application's navigation architecture is better. + +Reuse existing navigation where practical. + +============================================================ +17. PROFILE / SECURITY +============================================================ + +Preserve existing authentication. + +Improve where appropriate. + +Possible features: + +- profile +- password/security +- PIN +- biometric lock +- logout +- notification settings +- currency +- theme +- privacy +- data export +- account deletion + +Never expose another user's financial information. + +============================================================ +18. FINANCIAL DATA MODEL +============================================================ + +This is CRITICAL. + +Design a consistent transaction model. + +Every transaction should have: + +- id +- user/owner context +- amount +- fromWallet +- toWallet +- timestamp +- category +- description +- items +- attachments +- metadata + +Wallet balance must be deterministic. + +Avoid duplicated balance calculations. + +Do not use floating point for money. + +Backend: +Use Java BigDecimal. + +Flutter: +Use a safe monetary representation appropriate for the existing architecture. + +Use database transactions for financial operations. + +Ensure edits/deletes correctly update financial calculations. + +============================================================ +19. NEGATIVE AMOUNTS +============================================================ + +Negative values are explicitly allowed. + +Define and document exactly how negative transactions affect: + +- from wallet +- to wallet +- wallet balance +- income +- expense +- reports +- budgets + +Do not let different parts of the application interpret negative amounts differently. + +The backend is the source of truth. + +============================================================ +20. BACKEND +============================================================ + +Inspect the existing Spring Boot architecture first. + +Reuse: + +- controllers +- services +- repositories +- DTOs +- entities +- authentication +- exception handling +- migrations + +Extend existing APIs rather than creating duplicates. + +Use: + +- DTO validation +- service-layer business logic +- transaction boundaries +- authorization +- consistent API errors + +Financial business rules belong on the backend. + +Never rely exclusively on Flutter validation. + +============================================================ +21. DATABASE +============================================================ + +Before modifying entities: + +1. Understand existing schema. +2. Understand existing data. +3. Preserve existing records. +4. Create migrations. +5. Avoid destructive migrations. +6. Provide defaults for old records. +7. Maintain backward compatibility where practical. + +Never silently delete user financial data. + +============================================================ +22. FLUTTER ARCHITECTURE +============================================================ + +Inspect the current Flutter architecture first. + +Preserve existing: + +- state management +- routing +- API client +- repository pattern +- dependency injection +- theme +- widgets + +Do NOT replace state management just because another solution is preferred. + +Create reusable: + +- wallet widgets +- transaction widgets +- dashboard cards +- ledger rows +- filters +- forms +- dialogs +- charts +- attachment components + +Maintain iOS/Android parity. + +============================================================ +23. TESTING +============================================================ + +Backend tests: + +- unit tests +- service tests +- controller tests +- authorization tests +- transaction tests +- balance tests + +Flutter tests: + +- widget tests +- transaction form tests +- wallet tests +- balance tests +- dashboard tests +- attachment tests + +Critical scenarios: + +1. Create wallet +2. Create income +3. Create expense +4. Transfer money +5. Negative transaction +6. Edit transaction +7. Delete transaction +8. Upload attachment +9. Replace attachment +10. Shared wallet +11. Budget calculation +12. Dashboard totals +13. Ledger running balance +14. Unauthorized wallet access + +============================================================ +24. PERFORMANCE +============================================================ + +The application should remain responsive with large transaction histories. + +Consider: + +- pagination +- lazy loading +- database indexes +- optimized queries +- cached dashboard summaries +- debounced search +- image compression +- thumbnail generation +- background upload +- efficient chart data + +Do not optimize prematurely. + +Measure first where possible. + +============================================================ +25. ACCESSIBILITY +============================================================ + +Support: + +- readable typography +- sufficient contrast +- semantic labels +- screen-reader friendly controls +- touch-friendly controls +- scalable text where practical + +Do not rely only on color to communicate financial status. + +============================================================ +26. IMPLEMENTATION PHASES +============================================================ + +Do NOT implement everything in one giant change. + +PHASE 1: +Repository analysis +Architecture understanding +Data model analysis +Existing functionality inventory +Implementation plan + +PHASE 2: +Wallet/account model +Transaction model +Balance calculation +Create/edit transaction + +PHASE 3: +Dashboard +Ledger +Search +Filters + +PHASE 4: +Attachments +Items +Categories +Wallet sharing + +PHASE 5: +Budgets +Reports +Analytics +Recurring transactions + +PHASE 6: +Notifications +Onboarding +Guided tours +Profile/security + +PHASE 7: +UI polish +Animations +Accessibility +Performance +Testing +iOS/Android validation + +After every phase: + +- build backend +- run backend tests +- run Flutter analyzer +- run Flutter tests +- fix compilation errors +- fix test failures +- verify affected functionality + +============================================================ +27. CODE QUALITY RULES +============================================================ + +Follow existing project conventions. + +Prefer small, maintainable changes. + +Avoid unnecessary abstractions. + +Avoid duplicate models/services. + +Avoid dead code. + +Avoid magic constants. + +Use meaningful names. + +Document complicated financial business rules. + +Do not hide errors. + +Do not suppress compiler warnings simply to make builds pass. + +Do not disable tests. + +Do not remove tests to make the implementation pass. + +============================================================ +28. GIT / CHANGE SAFETY +============================================================ + +Before major modifications: + +Inspect git status. + +Do not overwrite unrelated user changes. + +Do not reset/revert user work. + +Do not delete files unless you have established they are obsolete. + +Keep changes logically grouped. + +============================================================ +29. IMPORTANT AGENT BEHAVIOR +============================================================ + +When uncertain: + +1. Inspect the code. +2. Search for existing implementation. +3. Follow existing conventions. +4. Make the smallest safe change. + +Do not guess the architecture. + +Do not assume an API exists. + +Do not create duplicate functionality. + +Do not rewrite working modules unnecessarily. + +If a requirement conflicts with existing behavior, explain the conflict before making a destructive architectural change. + +============================================================ +30. FIRST RESPONSE / FIRST ACTION +============================================================ + +DO NOT IMPLEMENT FEATURES YET. + +First inspect the repository. + +Then report: + +A. Current architecture +B. Flutter architecture +C. Spring Boot architecture +D. Current database model +E. Current account/wallet model +F. Current transaction workflow +G. Current dashboard +H. Current attachment implementation +I. Current wallet sharing implementation +J. Existing reusable functionality +K. Existing bugs/problems +L. Proposed target architecture +M. Database migration strategy +N. API changes required +O. Flutter screens/components required +P. Implementation phases + +Then begin PHASE 1. + +Do not proceed with a massive rewrite. + +Implement incrementally and verify every phase. \ No newline at end of file