diff --git a/.github/workflows/flutter-build.yml b/.github/workflows/flutter-build.yml new file mode 100644 index 00000000000..6484e164dd9 --- /dev/null +++ b/.github/workflows/flutter-build.yml @@ -0,0 +1,154 @@ +name: Flutter Mobile Build + +on: + push: + branches: + - main + paths: + - 'mobile/lib/**' + - 'mobile/android/**' + - 'mobile/ios/**' + - 'mobile/pubspec.yaml' + - '.github/workflows/flutter-build.yml' + pull_request: + paths: + - 'mobile/lib/**' + - 'mobile/android/**' + - 'mobile/ios/**' + - 'mobile/pubspec.yaml' + - '.github/workflows/flutter-build.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + build-android: + name: Build Android APK + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Java + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: '3.32.4' + channel: 'stable' + cache: true + + - name: Get dependencies + working-directory: mobile + run: flutter pub get + + - name: Generate app icons + working-directory: mobile + run: flutter pub run flutter_launcher_icons + + - name: Analyze code + working-directory: mobile + run: flutter analyze --no-fatal-infos + + - name: Run tests + working-directory: mobile + run: flutter test + + - name: Decode and setup keystore + env: + KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }} + KEY_STORE_PASSWORD: ${{ secrets.KEY_STORE_PASSWORD }} + KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} + KEY_ALIAS: ${{ secrets.KEY_ALIAS }} + run: | + echo "$KEYSTORE_BASE64" | base64 -d > mobile/android/app/upload-keystore.jks + echo "storePassword=$KEY_STORE_PASSWORD" >> mobile/android/key.properties + echo "keyPassword=$KEY_PASSWORD" >> mobile/android/key.properties + echo "keyAlias=$KEY_ALIAS" >> mobile/android/key.properties + echo "storeFile=upload-keystore.jks" >> mobile/android/key.properties + + - name: Build APK (Release) + working-directory: mobile + run: flutter build apk --release + + - name: Upload APK artifact + uses: actions/upload-artifact@v4 + with: + name: app-release-apk + path: mobile/build/app/outputs/flutter-apk/app-release.apk + retention-days: 30 + + - name: Build App Bundle (Release) + working-directory: mobile + run: flutter build appbundle --release + + - name: Upload AAB artifact + uses: actions/upload-artifact@v4 + with: + name: app-release-aab + path: mobile/build/app/outputs/bundle/release/app-release.aab + retention-days: 30 + + build-ios: + name: Build iOS IPA + runs-on: macos-latest + timeout-minutes: 45 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: '3.32.4' + channel: 'stable' + cache: true + + - name: Get dependencies + working-directory: mobile + run: flutter pub get + + - name: Generate app icons + working-directory: mobile + run: flutter pub run flutter_launcher_icons + + - name: Install CocoaPods dependencies + working-directory: mobile/ios + run: pod install + + - name: Analyze code + working-directory: mobile + run: flutter analyze --no-fatal-infos + + - name: Run tests + working-directory: mobile + run: flutter test + + - name: Build iOS (No Code Signing) + working-directory: mobile + run: flutter build ios --release --no-codesign + + - name: Create IPA archive info + working-directory: mobile + run: | + echo "iOS build completed successfully" > build/ios-build-info.txt + echo "Build date: $(date)" >> build/ios-build-info.txt + echo "Note: This build is not code-signed and cannot be installed on physical devices" >> build/ios-build-info.txt + echo "For distribution, you need to configure code signing with Apple certificates" >> build/ios-build-info.txt + + - name: Upload iOS build artifact + uses: actions/upload-artifact@v4 + with: + name: ios-build-unsigned + path: | + mobile/build/ios/iphoneos/Runner.app + mobile/build/ios-build-info.txt + retention-days: 30 diff --git a/mobile/.gitignore b/mobile/.gitignore new file mode 100644 index 00000000000..39d59563bb5 --- /dev/null +++ b/mobile/.gitignore @@ -0,0 +1,49 @@ +# Flutter / Dart +.dart_tool/ +.packages +.pub-cache/ +.pub/ +build/ +.flutter-plugins +.flutter-plugins-dependencies +*.iml +.metadata + +# Android +android/.gradle/ +android/local.properties +android/app/build/ +android/gradlew +android/gradlew.bat +android/key.properties +android/app/*.keystore +android/app/*.jks +keystore-base64.txt + +# iOS +ios/Pods/ +ios/.symlinks/ +ios/Flutter/Flutter.framework +ios/Flutter/Flutter.podspec +ios/Flutter/Generated.xcconfig +ios/Runner.xcworkspace/ +ios/Podfile.lock + +# IDE +.idea/ +*.iml +.vscode/ + +# macOS +.DS_Store +*/.DS_Store + +# Environment +.env +.env.* + +# Logs +*.log + +# Miscellaneous +_codeql_detected_source_root diff --git a/mobile/README.md b/mobile/README.md new file mode 100644 index 00000000000..5504313e115 --- /dev/null +++ b/mobile/README.md @@ -0,0 +1,196 @@ +# Sure Mobile + +A Flutter mobile application for [Sure](https://github.com/we-promise/sure) personal finance management system. This is the mobile client that connects to the Sure backend server. + +## About + +This app is a mobile companion to the [Sure personal finance management system](https://github.com/we-promise/sure). It provides basic functionality to: + +- **Login** - Authenticate with your Sure Finance server +- **View Balance** - See all your accounts and their balances + +For more detailed technical documentation, see [docs/TECHNICAL_GUIDE.md](docs/TECHNICAL_GUIDE.md). + +## Features + +- 🔐 Secure authentication with OAuth 2.0 +- 📱 Cross-platform support (Android & iOS) +- 💰 View all linked accounts +- 🎨 Material Design 3 with light/dark theme support +- 🔄 Token refresh for persistent sessions +- 🔒 Two-factor authentication (MFA) support + +## Requirements + +- Flutter SDK >= 3.0.0 +- Dart SDK >= 3.0.0 +- Android SDK (for Android builds) +- Xcode (for iOS builds) + +## Getting Started + +### 1. Install Flutter + +Follow the official Flutter installation guide: https://docs.flutter.dev/get-started/install + +### 2. Install Dependencies + +```bash +flutter pub get + +# For iOS development, also install CocoaPods dependencies +cd ios +pod install +cd .. +``` + +### 3. Generate App Icons + +```bash +flutter pub run flutter_launcher_icons +``` + +This step generates the app icons for all platforms based on the source icon in `assets/icon/app_icon.png`. This is required before building the app locally. + +### 4. Configure API Endpoint + +Edit `lib/services/api_config.dart` to point to your Sure Finance server: + +```dart +// For local development with Android emulator +static String _baseUrl = 'http://10.0.2.2:3000'; + +// For local development with iOS simulator +static String _baseUrl = 'http://localhost:3000'; + +// For production +static String _baseUrl = 'https://your-sure-server.com'; +``` + +### 5. Run the App + +```bash +# For Android +flutter run -d android + +# For iOS +flutter run -d ios + +# For web (development only) +flutter run -d chrome +``` + +## Project Structure + +``` +. +├── lib/ +│ ├── main.dart # App entry point +│ ├── models/ # Data models +│ │ ├── account.dart +│ │ ├── auth_tokens.dart +│ │ └── user.dart +│ ├── providers/ # State management +│ │ ├── auth_provider.dart +│ │ └── accounts_provider.dart +│ ├── screens/ # UI screens +│ │ ├── login_screen.dart +│ │ └── dashboard_screen.dart +│ ├── services/ # API services +│ │ ├── api_config.dart +│ │ ├── auth_service.dart +│ │ ├── accounts_service.dart +│ │ └── device_service.dart +│ └── widgets/ # Reusable widgets +│ └── account_card.dart +├── android/ # Android configuration +├── ios/ # iOS configuration +├── pubspec.yaml # Dependencies +└── README.md +``` + +## API Integration + +This app integrates with the Sure Finance Rails API: + +### Authentication +- `POST /api/v1/auth/login` - User authentication +- `POST /api/v1/auth/signup` - User registration +- `POST /api/v1/auth/refresh` - Token refresh + +### Accounts +- `GET /api/v1/accounts` - Fetch user accounts + +### Transactions +- `GET /api/v1/transactions` - Get all transactions (optionally filter by `account_id` query parameter) +- `POST /api/v1/transactions` - Create a new transaction +- `PUT /api/v1/transactions/:id` - Update an existing transaction +- `DELETE /api/v1/transactions/:id` - Delete a transaction + +#### Transaction POST Request Format +```json +{ + "transaction": { + "account_id": "2980ffb0-f595-4572-be0e-7b9b9c53949b", // required + "name": "test", // required + "date": "2025-07-15", // required + "amount": 100, // optional, defaults to 0 + "currency": "AUD", // optional, defaults to your profile currency + "nature": "expense" // optional, defaults to "expense", other option is "income" + } +} +``` + +## CI/CD + +The app includes automated CI/CD via GitHub Actions (`.github/workflows/flutter-build.yml`): + +- **Triggers**: On push/PR to `main` branch when Flutter files change +- **Android Build**: Generates release APK and AAB artifacts +- **iOS Build**: Generates iOS release build (unsigned) +- **Quality Checks**: Code analysis and tests run before building + +### Downloading Build Artifacts + +After a successful CI run, download artifacts from the GitHub Actions workflow: +- `app-release-apk` - Android APK file +- `app-release-aab` - Android App Bundle (for Play Store) +- `ios-build-unsigned` - iOS app bundle (unsigned, see [iOS build guide](docs/iOS_BUILD.md) for signing) + +## Building for Release + +### Android + +```bash +flutter build apk --release +# or for App Bundle +flutter build appbundle --release +``` + +### iOS + +```bash +# Ensure CocoaPods dependencies are installed first +cd ios && pod install && cd .. + +# Build iOS release +flutter build ios --release +``` + +For detailed iOS build instructions, troubleshooting, and CI/CD setup, see [docs/iOS_BUILD.md](docs/iOS_BUILD.md). + +## Future Expansion + +This app provides a foundation for additional features: + +- Transaction history +- Account sync +- Budget management +- Investment tracking +- AI chat assistant +- Push notifications +- Biometric authentication + +## License + +This project is distributed under the AGPLv3 license. diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml new file mode 100644 index 00000000000..55a32b5759e --- /dev/null +++ b/mobile/analysis_options.yaml @@ -0,0 +1,8 @@ +include: package:flutter_lints/flutter.yaml + +linter: + rules: + - avoid_print + - prefer_const_constructors + - prefer_const_declarations + - prefer_final_fields diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle new file mode 100644 index 00000000000..fbc167c0214 --- /dev/null +++ b/mobile/android/app/build.gradle @@ -0,0 +1,59 @@ +plugins { + id "com.android.application" + id "kotlin-android" + id "dev.flutter.flutter-gradle-plugin" +} + +def keystoreProperties = new Properties() +def keystorePropertiesFile = rootProject.file('key.properties') +if (keystorePropertiesFile.exists()) { + keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) +} + +android { + namespace "com.sure.mobile" + compileSdkVersion flutter.compileSdkVersion + ndkVersion flutter.ndkVersion + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = '1.8' + } + + signingConfigs { + release { + keyAlias keystoreProperties['keyAlias'] + keyPassword keystoreProperties['keyPassword'] + storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null + storePassword keystoreProperties['storePassword'] + } + } + + defaultConfig { + applicationId "com.sure.mobile" + minSdkVersion 21 + targetSdkVersion flutter.targetSdkVersion + versionCode 1 + versionName "1.0.0" + } + + buildTypes { + release { + signingConfig signingConfigs.release + minifyEnabled false + shrinkResources false + } + } +} + +flutter { + source '../..' +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" +} diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000000..57597541354 --- /dev/null +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/android/app/src/main/kotlin/com/sure/mobile/MainActivity.kt b/mobile/android/app/src/main/kotlin/com/sure/mobile/MainActivity.kt new file mode 100644 index 00000000000..8851101f1b9 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/com/sure/mobile/MainActivity.kt @@ -0,0 +1,5 @@ +package com.sure.mobile + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity: FlutterActivity() diff --git a/mobile/android/app/src/main/res/drawable/launch_background.xml b/mobile/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 00000000000..5782842b813 --- /dev/null +++ b/mobile/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,4 @@ + + + + diff --git a/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000000..b9a37545ac5 Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000000..8fab5e751a7 Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000000..a80577c6727 Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000000..b1ed54fb043 Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000000..d6018c91587 Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/mobile/android/app/src/main/res/values/styles.xml b/mobile/android/app/src/main/res/values/styles.xml new file mode 100644 index 00000000000..ff81bae8638 --- /dev/null +++ b/mobile/android/app/src/main/res/values/styles.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/mobile/android/build.gradle b/mobile/android/build.gradle new file mode 100644 index 00000000000..68b15a84144 --- /dev/null +++ b/mobile/android/build.gradle @@ -0,0 +1,31 @@ +buildscript { + ext.kotlin_version = '1.9.0' + repositories { + google() + mavenCentral() + } + + dependencies { + classpath 'com.android.tools.build:gradle:8.1.0' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +rootProject.buildDir = '../build' +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" +} +subprojects { + project.evaluationDependsOn(':app') +} + +tasks.register("clean", Delete) { + delete rootProject.buildDir +} diff --git a/mobile/android/gradle.properties b/mobile/android/gradle.properties new file mode 100644 index 00000000000..598d13fee44 --- /dev/null +++ b/mobile/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx4G +android.useAndroidX=true +android.enableJetifier=true diff --git a/mobile/android/gradle/wrapper/gradle-wrapper.properties b/mobile/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000000..d951fac2bf3 --- /dev/null +++ b/mobile/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-all.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/mobile/android/settings.gradle b/mobile/android/settings.gradle new file mode 100644 index 00000000000..2919a540834 --- /dev/null +++ b/mobile/android/settings.gradle @@ -0,0 +1,27 @@ +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return flutterSdkPath + } + settings.ext.flutterSdkPath = flutterSdkPath() + + includeBuild("${settings.ext.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.5.0" apply false + id "org.jetbrains.kotlin.android" version "1.9.24" apply false +} + +rootProject.name = 'sure_mobile' +include ':app' diff --git a/mobile/assets/icon/app_icon.png b/mobile/assets/icon/app_icon.png new file mode 100644 index 00000000000..330e4a504e1 Binary files /dev/null and b/mobile/assets/icon/app_icon.png differ diff --git a/mobile/assets/images/.gitkeep b/mobile/assets/images/.gitkeep new file mode 100644 index 00000000000..c8c6c54970f --- /dev/null +++ b/mobile/assets/images/.gitkeep @@ -0,0 +1,2 @@ +# Placeholder for assets +This directory contains image assets for the Sure Mobile app. diff --git a/mobile/docs/SIGNING_SETUP.md b/mobile/docs/SIGNING_SETUP.md new file mode 100644 index 00000000000..3eccf0b256a --- /dev/null +++ b/mobile/docs/SIGNING_SETUP.md @@ -0,0 +1,65 @@ +# Android 签名设置说明 + +## GitHub Secrets 配置 + +为了让 CI/CD 自动签名 APK/AAB,你需要在 GitHub 仓库中设置以下 Secrets: + +### 步骤 1: 获取 Keystore Base64 编码 + +keystore 的 base64 编码已经生成在项目根目录的 `keystore-base64.txt` 文件中。 + +查看内容: +```bash +cat keystore-base64.txt +``` + +### 步骤 2: 在 GitHub 上添加 Secrets + +前往你的 GitHub 仓库: +1. 点击 **Settings** (设置) +2. 在左侧菜单中点击 **Secrets and variables** > **Actions** +3. 点击 **New repository secret** 按钮 +4. 添加以下四个 secrets: + +| Secret 名称 | 值 | +|------------|-----| +| `KEYSTORE_BASE64` | 从 `keystore-base64.txt` 复制的 base64 字符串 | +| `KEY_STORE_PASSWORD` | 你的 keystore 密码 | +| `KEY_PASSWORD` | 你的 key 密码 | +| `KEY_ALIAS` | 你的 key alias | + +### 步骤 3: 验证设置 + +设置完成后,推送代码到 main 分支或创建 Pull Request,CI/CD 将自动: +1. 运行测试 +2. 构建签名的 APK +3. 构建签名的 AAB +4. 上传构建产物到 GitHub Actions artifacts + +## 本地构建 + +本地构建已经配置好,`android/key.properties` 文件包含签名信息。 + +本地构建签名版本: +```bash +flutter build apk --release +flutter build appbundle --release +``` + +## 安全注意事项 + +- ✅ `key.properties` 和 keystore 文件已添加到 `.gitignore` +- ✅ 这些文件不会被提交到 Git 仓库 +- ✅ CI/CD 使用 GitHub Secrets 安全存储签名信息 +- ⚠️ 请妥善保管 `keystore-base64.txt` 文件,设置完 GitHub Secrets 后可以删除 + +## Keystore 信息 + +- **文件位置**: `android/app/upload-keystore.jks` +- **有效期**: 10000 天 + +⚠️ **重要提示**: +- 请妥善保管你的 keystore 密码、key 密码和 alias +- 这些信息只存储在本地的 `android/key.properties` 文件中(已添加到 .gitignore) +- GitHub Secrets 中也需要配置这些信息 +- 请务必备份 keystore 文件,丢失后将无法更新已发布的应用! diff --git a/mobile/docs/TECHNICAL_GUIDE.md b/mobile/docs/TECHNICAL_GUIDE.md new file mode 100644 index 00000000000..742a9e16eb4 --- /dev/null +++ b/mobile/docs/TECHNICAL_GUIDE.md @@ -0,0 +1,457 @@ +# Sure Mobile - Technical Documentation + +## Project Overview + +Sure Mobile is the mobile application for the [Sure Personal Finance Management System](https://github.com/we-promise/sure), developed with Flutter framework and supporting both Android and iOS platforms. This application provides core mobile functionality for the Sure finance management system, allowing users to view and manage their financial accounts anytime, anywhere. + +### Backend Relationship + +This application is a client app for the Sure Finance Management System and requires connection to the Sure backend server (Rails API) to function properly. Backend project: https://github.com/we-promise/sure + +## Core Features + +### 1. Backend Configuration +- **Server Address Configuration**: Configure Sure backend server URL on first launch +- **Connection Testing**: Provides connection test functionality to verify server availability +- **Address Persistence**: Server address is saved locally and automatically loaded on next startup + +### 2. User Authentication +- **Login**: Support email and password login +- **Two-Factor Authentication (MFA)**: Support OTP verification code secondary verification +- **User Registration**: Support new user registration (backend supported) +- **Token Management**: + - Access Token for API request authentication + - Refresh Token for refreshing expired Access Tokens + - Tokens securely stored in device's secure storage +- **Auto-login**: Automatically checks local tokens on app startup and logs in if valid +- **Device Information Tracking**: Records device information on login for backend session management + +### 3. Account Management +- **Account List Display**: Shows all user financial accounts +- **Account Classification**: + - **Asset Accounts**: Bank accounts, investment accounts, cryptocurrency, real estate, vehicles, etc. + - **Liability Accounts**: Credit cards, loans, etc. + - **Other Accounts**: Uncategorized accounts +- **Account Type Support**: + - Depository + - Credit Card + - Investment + - Loan + - Property + - Vehicle + - Crypto + - Other assets/liabilities +- **Balance Display**: Shows current balance and currency type for each account +- **Pull to Refresh**: Supports pull-to-refresh for account data + +## Technical Architecture + +### Tech Stack +- **Framework**: Flutter 3.0+ +- **Language**: Dart 3.0+ +- **State Management**: Provider +- **Network Requests**: http +- **Local Storage**: + - shared_preferences (non-sensitive data, like server URL) + - flutter_secure_storage (sensitive data, like tokens) + +### Project Structure + +``` +lib/ +├── main.dart # App entry point +├── models/ # Data models +│ ├── account.dart # Account model +│ ├── auth_tokens.dart # Authentication token model +│ └── user.dart # User model +├── providers/ # State management +│ ├── auth_provider.dart # Authentication state management +│ └── accounts_provider.dart # Accounts state management +├── screens/ # Screens +│ ├── backend_config_screen.dart # Backend configuration screen +│ ├── login_screen.dart # Login screen +│ └── dashboard_screen.dart # Main screen (account list) +├── services/ # Business services +│ ├── api_config.dart # API configuration +│ ├── auth_service.dart # Authentication service +│ ├── accounts_service.dart # Accounts service +│ └── device_service.dart # Device information service +└── widgets/ # Reusable widgets + └── account_card.dart # Account card widget +``` + +## Application Flow Details + +### Startup Flow + +``` +App Launch + ↓ +Initialize ApiConfig (load saved backend URL) + ↓ +Check if backend URL is configured + ├─ No → Show backend configuration screen + │ ↓ + │ Enter and test URL + │ ↓ + │ Save configuration + │ ↓ + └─ Yes → Check Token + ├─ Invalid or not exists → Show login screen + │ ↓ + │ User login + │ ↓ + │ Save tokens and user info + │ ↓ + └─ Valid → Enter Dashboard +``` + +### Authentication Flow + +#### 1. Login Flow (login_screen.dart) + +``` +User enters email and password + ↓ +Click login button + ↓ +AuthProvider.login() + ↓ +Collect device information (DeviceService) + ↓ +Call AuthService.login() + ↓ +Send POST /api/v1/auth/login + ├─ Success (200) + │ ↓ + │ Save Access Token and Refresh Token + │ ↓ + │ Save user information + │ ↓ + │ Navigate to dashboard + │ + ├─ MFA Required (401 + mfa_required) + │ ↓ + │ Show OTP input field + │ ↓ + │ User enters verification code + │ ↓ + │ Re-login (with OTP) + │ + └─ Failure + ↓ + Show error message +``` + +#### 2. Token Refresh Flow (auth_provider.dart) + +``` +Need to access API + ↓ +Check if Access Token is expired + ├─ Not expired → Use directly + │ + └─ Expired + ↓ + Get Refresh Token + ↓ + Call AuthService.refreshToken() + ↓ + Send POST /api/v1/auth/refresh + ├─ Success + │ ↓ + │ Save new tokens + │ ↓ + │ Return new Access Token + │ + └─ Failure + ↓ + Clear tokens + ↓ + Return to login screen +``` + +### Account Data Flow + +#### 1. Fetch Account List (dashboard_screen.dart) + +``` +Enter dashboard + ↓ +_loadAccounts() + ↓ +Get valid Access Token from AuthProvider + ├─ Token invalid + │ ↓ + │ Logout and return to login screen + │ + └─ Token valid + ↓ + AccountsProvider.fetchAccounts() + ↓ + Call AccountsService.getAccounts() + ↓ + Send GET /api/v1/accounts + ├─ Success (200) + │ ↓ + │ Parse account data + │ ↓ + │ Group by classification (asset/liability) + │ ↓ + │ Update UI + │ + ├─ Unauthorized (401) + │ ↓ + │ Clear local data + │ ↓ + │ Return to login screen + │ + └─ Other errors + ↓ + Show error message +``` + +#### 2. Account Classification Logic (accounts_provider.dart) + +```dart +// Asset accounts: classification == 'asset' +List get assetAccounts => + accounts.where((a) => a.isAsset).toList(); + +// Liability accounts: classification == 'liability' +List get liabilityAccounts => + accounts.where((a) => a.isLiability).toList(); + +// Uncategorized accounts +List get uncategorizedAccounts => + accounts.where((a) => !a.isAsset && !a.isLiability).toList(); +``` + +### UI State Management + +The app uses Provider for state management, with two main providers: + +#### AuthProvider (auth_provider.dart) +Manages authentication-related state: +- `isAuthenticated`: Whether user is logged in +- `isLoading`: Whether loading is in progress +- `user`: Current user information +- `errorMessage`: Error message +- `mfaRequired`: Whether MFA verification is required + +#### AccountsProvider (accounts_provider.dart) +Manages account data state: +- `accounts`: All accounts list +- `isLoading`: Whether loading is in progress +- `errorMessage`: Error message +- `assetAccounts`: Asset accounts list +- `liabilityAccounts`: Liability accounts list + +## API Endpoints + +The app interacts with the backend through the following API endpoints: + +### Authentication +- `POST /api/v1/auth/login` - User login +- `POST /api/v1/auth/signup` - User registration +- `POST /api/v1/auth/refresh` - Refresh token + +### Accounts +- `GET /api/v1/accounts` - Get account list (supports pagination) + +### Health Check +- `GET /sessions/new` - Verify backend service availability + +## Data Models + +### Account Model +```dart +class Account { + final String id; // Account ID (UUID) + final String name; // Account name + final String balance; // Balance (string format) + final String currency; // Currency type (e.g., USD, TWD) + final String? classification; // Classification (asset/liability) + final String accountType; // Account type (depository, credit_card, etc.) +} +``` + +### AuthTokens Model +```dart +class AuthTokens { + final String accessToken; // Access token + final String refreshToken; // Refresh token + final int expiresIn; // Expiration time (seconds) + final DateTime expiresAt; // Expiration timestamp +} +``` + +### User Model +```dart +class User { + final String id; // User ID (UUID) + final String email; // Email + final String firstName; // First name + final String lastName; // Last name +} +``` + +## Security Mechanisms + +### 1. Secure Token Storage +- Uses `flutter_secure_storage` for encrypted token storage +- Tokens are never saved in plain text in regular storage +- Sensitive data is automatically cleared when app is uninstalled + +### 2. Token Expiration Handling +- Access Token is automatically refreshed using Refresh Token after expiration +- Requires re-login when Refresh Token is invalid +- All API requests check token validity + +### 3. Device Tracking +- Records device information on each login (device ID, model, OS) +- Backend can manage user sessions based on device information + +### 4. HTTPS Support +- Production environment enforces HTTPS +- Development environment supports HTTP (local testing only) + +## Theme & UI + +### Material Design 3 +The app follows Material Design 3 specifications: +- Dynamic color scheme (based on seed color #6366F1) +- Rounded cards (12px border radius) +- Responsive layout +- Dark mode support (follows system) + +### Responsive Design +- Pull-to-refresh support +- Loading state indicators +- Error state display +- Empty state prompts + +## Development & Debugging + +### Environment Configuration + +#### Android Emulator +```dart +// lib/services/api_config.dart +static String _baseUrl = 'http://10.0.2.2:3000'; +``` + +#### iOS Simulator +```dart +static String _baseUrl = 'http://localhost:3000'; +``` + +#### Physical Device +```dart +static String _baseUrl = 'http://YOUR_COMPUTER_IP:3000'; +// Or use production URL +static String _baseUrl = 'https://your-domain.com'; +``` + +### Common Commands + +```bash +# Install dependencies +flutter pub get + +# Run app +flutter run + +# Build APK +flutter build apk --release + +# Build App Bundle +flutter build appbundle --release + +# Build iOS +flutter build ios --release + +# Code analysis +flutter analyze + +# Run tests +flutter test +``` + +### Debugging Tips + +1. **View Network Requests**: + - Android Studio: Use Network Profiler + - Or add `print()` statements in code + +2. **View Stored Data**: + ```dart + // Add at debugging point + final prefs = await SharedPreferences.getInstance(); + print('Backend URL: ${prefs.getString('backend_url')}'); + ``` + +3. **Clear Local Data**: + ```bash + # Android + adb shell pm clear com.example.sure_mobile + + # iOS Simulator + # Long press app icon -> Delete app -> Reinstall + ``` + +## CI/CD + +The project is configured with GitHub Actions automated builds: + +### Trigger Conditions +- Push to `main` branch +- Pull Request to `main` branch +- Only triggers when Flutter-related files change + +### Build Process +1. Code analysis (`flutter analyze`) +2. Run tests (`flutter test`) +3. Android Release build (APK + AAB) +4. iOS Release build (unsigned) +5. Upload build artifacts + +### Download Build Artifacts +Available on GitHub Actions page: +- `app-release-apk`: Android APK file +- `app-release-aab`: Android App Bundle (for Google Play) +- `ios-build-unsigned`: iOS app bundle (requires signing for distribution) + +## Future Extensions + +### Planned Features +- **Transaction History**: View and manage transaction history +- **Account Sync**: Support automatic bank account synchronization +- **Budget Management**: Set and track budgets +- **Investment Tracking**: View investment returns +- **AI Assistant**: Financial advice and analysis +- **Push Notifications**: Transaction alerts and account change notifications +- **Biometric Authentication**: Fingerprint/Face ID quick login +- **Multi-language Support**: Chinese/English interface switching +- **Chart Analysis**: Financial data visualization + +### Technical Improvements +- Offline mode support +- Data caching optimization +- More robust error handling +- Unit tests and integration tests +- Performance optimization + +## License + +This project is distributed under the AGPLv3 license. + +## Contributing + +Issues and Pull Requests are welcome! + +## Related Links + +- **Backend Project**: https://github.com/we-promise/sure +- **Flutter Official Documentation**: https://docs.flutter.dev +- **Dart Language Documentation**: https://dart.dev/guides diff --git a/mobile/docs/iOS_BUILD.md b/mobile/docs/iOS_BUILD.md new file mode 100644 index 00000000000..e766f632d6e --- /dev/null +++ b/mobile/docs/iOS_BUILD.md @@ -0,0 +1,155 @@ +# iOS Build Guide + +## Issue Diagnosis: module 'flutter_secure_storage' not found + +### Root Cause +This error occurs because CocoaPods dependencies have not been installed. `flutter_secure_storage` is a Flutter plugin that requires native platform support, and its iOS native code must be installed via CocoaPods. + +### Solution + +#### First-time Setup or After Dependency Updates +```bash +# 1. Get Flutter dependencies +flutter pub get + +# 2. Navigate to iOS directory and install CocoaPods dependencies +cd ios +pod install +cd .. +``` + +#### Clean Build (if encountering issues) +```bash +# Clean Flutter build cache +flutter clean + +# Re-fetch dependencies +flutter pub get + +# Clean and reinstall Pods +cd ios +rm -rf Pods Podfile.lock +pod install +cd .. +``` + +## Local Building + +### Method 1: Using Flutter CLI +```bash +# Debug mode +flutter build ios --debug + +# Release mode (requires Apple Developer certificate) +flutter build ios --release + +# Release mode (no code signing, for build testing only) +flutter build ios --release --no-codesign +``` + +### Method 2: Using Xcode +1. Ensure you have run `pod install` +2. Open `ios/Runner.xcworkspace` (**Note: NOT .xcodeproj**) +3. Select target device or simulator +4. Click Run button or press Cmd+R + +## CI/CD Automated Builds + +### GitHub Actions Workflow + +The project is configured with automated iOS build process, triggered by: +- Push to `main` branch +- Pull Requests +- Manual trigger (workflow_dispatch) + +#### Build Steps +1. **Environment Setup**: macOS runner + Flutter 3.32.4 +2. **Dependency Installation**: `flutter pub get` + `pod install` +3. **Code Analysis**: `flutter analyze` +4. **Test Execution**: `flutter test` +5. **iOS Build**: `flutter build ios --release --no-codesign` +6. **Artifact Upload**: Built .app file saved as artifact for 30 days + +#### Viewing Build Artifacts +1. Go to GitHub Actions page +2. Select the corresponding workflow run +3. Download `ios-build-unsigned` artifact + +**Note**: CI-built versions are not code-signed and cannot be installed directly on physical devices. + +## Code Signing and Distribution + +### Configuring Code Signing +To publish to the App Store or install on physical devices, you need: + +1. **Apple Developer Account** (Individual or Enterprise) +2. **Developer Certificates** + - Development Certificate + - Distribution Certificate +3. **Provisioning Profile** +4. **App ID** registered in Apple Developer Portal + +### Configuration in Xcode +1. Open `ios/Runner.xcworkspace` +2. Select Runner target +3. Go to "Signing & Capabilities" tab +4. Set Team (requires Apple ID login) +5. Set Bundle Identifier +6. Xcode will automatically manage certificates and Provisioning Profile + +### Building IPA for Distribution +```bash +# Build and archive using Xcode +flutter build ipa --release + +# IPA file location +# build/ios/ipa/*.ipa +``` + +## System Requirements + +### Development Environment +- macOS 12.0 or higher +- Xcode 14.0 or higher +- CocoaPods 1.11 or higher +- Flutter 3.32.4 (recommended) + +### Minimum iOS Version +- iOS 12.0 (defined in `ios/Podfile`) + +## Common Issues + +### Q: Why use .xcworkspace instead of .xcodeproj? +A: When a project uses CocoaPods, Pod dependencies are organized into a separate Xcode project. The `.xcworkspace` file contains both the main project and the Pods project, and must be used to ensure all dependencies are properly loaded. + +### Q: What to do after updating pubspec.yaml? +A: After adding or updating dependencies, you need to run: +```bash +flutter pub get +cd ios && pod install && cd .. +``` + +### Q: What if CI build fails? +A: Common causes: +1. Flutter version mismatch +2. Dependency conflicts +3. Pod installation failure +4. Code analysis or test failures + +Check GitHub Actions logs for detailed error information. + +### Q: How to configure code signing in CI? +A: You need to configure GitHub Secrets: +- Apple certificate (.p12 format, base64 encoded) +- Provisioning Profile +- Certificate password +- Keychain setup + +This requires additional configuration steps. Currently, CI uses the `--no-codesign` option for unsigned builds. + +## Related Documentation + +- [Flutter iOS Deployment Documentation](https://docs.flutter.dev/deployment/ios) +- [CocoaPods Official Guide](https://guides.cocoapods.org/) +- [Apple Developer Documentation](https://developer.apple.com/documentation/) +- [flutter_secure_storage Plugin Documentation](https://pub.dev/packages/flutter_secure_storage) diff --git a/mobile/ios/Flutter/AppFrameworkInfo.plist b/mobile/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 00000000000..7c569640062 --- /dev/null +++ b/mobile/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 12.0 + + diff --git a/mobile/ios/Flutter/Debug.xcconfig b/mobile/ios/Flutter/Debug.xcconfig new file mode 100644 index 00000000000..ec97fc6f302 --- /dev/null +++ b/mobile/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/mobile/ios/Flutter/Release.xcconfig b/mobile/ios/Flutter/Release.xcconfig new file mode 100644 index 00000000000..c4855bfe200 --- /dev/null +++ b/mobile/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/mobile/ios/Podfile b/mobile/ios/Podfile new file mode 100644 index 00000000000..2c068c404bd --- /dev/null +++ b/mobile/ios/Podfile @@ -0,0 +1,41 @@ +# Uncomment this line to define a global platform for your project +platform :ios, '12.0' + +# 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! + use_modular_headers! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 00000000000..9eaffcd01e9 --- /dev/null +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,491 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 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 */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.sure.mobile; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.sure.mobile; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.sure.mobile; + 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 */ + 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/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift new file mode 100644 index 00000000000..b6363034812 --- /dev/null +++ b/mobile/ios/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import UIKit +import Flutter + +@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/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000000..fc2c9e836a5 --- /dev/null +++ b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,62 @@ +{ + "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@2x.png", + "scale": "2x" + }, + { + "size": "29x29", + "idiom": "iphone", + "filename": "Icon-App-29x29@3x.png", + "scale": "3x" + }, + { + "size": "40x40", + "idiom": "iphone", + "filename": "Icon-App-40x40@2x.png", + "scale": "2x" + }, + { + "size": "40x40", + "idiom": "iphone", + "filename": "Icon-App-40x40@3x.png", + "scale": "3x" + }, + { + "size": "60x60", + "idiom": "iphone", + "filename": "Icon-App-60x60@2x.png", + "scale": "2x" + }, + { + "size": "60x60", + "idiom": "iphone", + "filename": "Icon-App-60x60@3x.png", + "scale": "3x" + }, + { + "size": "1024x1024", + "idiom": "ios-marketing", + "filename": "Icon-App-1024x1024@1x.png", + "scale": "1x" + } + ], + "info": { + "version": 1, + "author": "xcode" + } +} diff --git a/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 00000000000..0bedcf2fd46 --- /dev/null +++ b/mobile/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/mobile/ios/Runner/Base.lproj/LaunchScreen.storyboard b/mobile/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 00000000000..e1a975c651e --- /dev/null +++ b/mobile/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/ios/Runner/Base.lproj/Main.storyboard b/mobile/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 00000000000..e8a8c40b6e4 --- /dev/null +++ b/mobile/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/ios/Runner/GeneratedPluginRegistrant.h b/mobile/ios/Runner/GeneratedPluginRegistrant.h new file mode 100644 index 00000000000..3ab55947f36 --- /dev/null +++ b/mobile/ios/Runner/GeneratedPluginRegistrant.h @@ -0,0 +1,5 @@ +#import + +@interface GeneratedPluginRegistrant : NSObject ++ (void)registerWithRegistry:(NSObject*)registry; +@end diff --git a/mobile/ios/Runner/GeneratedPluginRegistrant.m b/mobile/ios/Runner/GeneratedPluginRegistrant.m new file mode 100644 index 00000000000..efe65ecccf6 --- /dev/null +++ b/mobile/ios/Runner/GeneratedPluginRegistrant.m @@ -0,0 +1,14 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#import "GeneratedPluginRegistrant.h" + +@implementation GeneratedPluginRegistrant + ++ (void)registerWithRegistry:(NSObject*)registry { +} + +@end diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist new file mode 100644 index 00000000000..01700d6e452 --- /dev/null +++ b/mobile/ios/Runner/Info.plist @@ -0,0 +1,62 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Sure Finance + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + sure_mobile + 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 + + UIViewControllerBasedStatusBarAppearance + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + sureapp + + + + + diff --git a/mobile/ios/Runner/Runner-Bridging-Header.h b/mobile/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 00000000000..9f3ddcf7507 --- /dev/null +++ b/mobile/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1,3 @@ +#import +#import +#import "GeneratedPluginRegistrant.h" diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart new file mode 100644 index 00000000000..2b94f17682d --- /dev/null +++ b/mobile/lib/main.dart @@ -0,0 +1,179 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'providers/auth_provider.dart'; +import 'providers/accounts_provider.dart'; +import 'providers/transactions_provider.dart'; +import 'screens/backend_config_screen.dart'; +import 'screens/login_screen.dart'; +import 'screens/dashboard_screen.dart'; +import 'services/api_config.dart'; + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + await ApiConfig.initialize(); + runApp(const SureApp()); +} + +class SureApp extends StatelessWidget { + const SureApp({super.key}); + + @override + Widget build(BuildContext context) { + return MultiProvider( + providers: [ + ChangeNotifierProvider(create: (_) => AuthProvider()), + ChangeNotifierProvider(create: (_) => AccountsProvider()), + ChangeNotifierProvider(create: (_) => TransactionsProvider()), + ], + child: MaterialApp( + title: 'Sure Finance', + debugShowCheckedModeBanner: false, + theme: ThemeData( + colorScheme: ColorScheme.fromSeed( + seedColor: const Color(0xFF6366F1), + brightness: Brightness.light, + ), + useMaterial3: true, + appBarTheme: const AppBarTheme( + centerTitle: true, + elevation: 0, + ), + cardTheme: CardThemeData( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + inputDecorationTheme: InputDecorationTheme( + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + filled: true, + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + minimumSize: const Size(double.infinity, 50), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + darkTheme: ThemeData( + colorScheme: ColorScheme.fromSeed( + seedColor: const Color(0xFF6366F1), + brightness: Brightness.dark, + ), + useMaterial3: true, + appBarTheme: const AppBarTheme( + centerTitle: true, + elevation: 0, + ), + cardTheme: CardThemeData( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + inputDecorationTheme: InputDecorationTheme( + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + filled: true, + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + minimumSize: const Size(double.infinity, 50), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + themeMode: ThemeMode.system, + routes: { + '/config': (context) => const BackendConfigScreen(), + '/login': (context) => const LoginScreen(), + '/dashboard': (context) => const DashboardScreen(), + }, + home: const AppWrapper(), + ), + ); + } +} + +class AppWrapper extends StatefulWidget { + const AppWrapper({super.key}); + + @override + State createState() => _AppWrapperState(); +} + +class _AppWrapperState extends State { + bool _isCheckingConfig = true; + bool _hasBackendUrl = false; + + @override + void initState() { + super.initState(); + _checkBackendConfig(); + } + + Future _checkBackendConfig() async { + final hasUrl = await ApiConfig.initialize(); + setState(() { + _hasBackendUrl = hasUrl; + _isCheckingConfig = false; + }); + } + + void _onBackendConfigSaved() { + setState(() { + _hasBackendUrl = true; + }); + } + + void _goToBackendConfig() { + setState(() { + _hasBackendUrl = false; + }); + } + + @override + Widget build(BuildContext context) { + if (_isCheckingConfig) { + return const Scaffold( + body: Center( + child: CircularProgressIndicator(), + ), + ); + } + + if (!_hasBackendUrl) { + return BackendConfigScreen( + onConfigSaved: _onBackendConfigSaved, + ); + } + + return Consumer( + builder: (context, authProvider, _) { + // Only show loading spinner during initial auth check + if (authProvider.isInitializing) { + return const Scaffold( + body: Center( + child: CircularProgressIndicator(), + ), + ); + } + + if (authProvider.isAuthenticated) { + return const DashboardScreen(); + } + + return LoginScreen( + onGoToSettings: _goToBackendConfig, + ); + }, + ); + } +} diff --git a/mobile/lib/models/account.dart b/mobile/lib/models/account.dart new file mode 100644 index 00000000000..046aa060115 --- /dev/null +++ b/mobile/lib/models/account.dart @@ -0,0 +1,66 @@ +class Account { + final String id; + final String name; + final String balance; + final String currency; + final String? classification; + final String accountType; + + Account({ + required this.id, + required this.name, + required this.balance, + required this.currency, + this.classification, + required this.accountType, + }); + + factory Account.fromJson(Map json) { + return Account( + id: json['id'].toString(), + name: json['name'] as String, + balance: json['balance'] as String, + currency: json['currency'] as String, + classification: json['classification'] as String?, + accountType: json['account_type'] as String, + ); + } + + bool get isAsset => classification == 'asset'; + bool get isLiability => classification == 'liability'; + + double get balanceAsDouble { + try { + // Remove commas and any other non-numeric characters except dots and minus signs + final cleanedBalance = balance.replaceAll(RegExp(r'[^\d.-]'), ''); + return double.parse(cleanedBalance); + } catch (e) { + return 0.0; + } + } + + String get displayAccountType { + switch (accountType) { + case 'depository': + return 'Bank Account'; + case 'credit_card': + return 'Credit Card'; + case 'investment': + return 'Investment'; + case 'loan': + return 'Loan'; + case 'property': + return 'Property'; + case 'vehicle': + return 'Vehicle'; + case 'crypto': + return 'Crypto'; + case 'other_asset': + return 'Other Asset'; + case 'other_liability': + return 'Other Liability'; + default: + return accountType; + } + } +} diff --git a/mobile/lib/models/auth_tokens.dart b/mobile/lib/models/auth_tokens.dart new file mode 100644 index 00000000000..0a3263bee9a --- /dev/null +++ b/mobile/lib/models/auth_tokens.dart @@ -0,0 +1,53 @@ +class AuthTokens { + final String accessToken; + final String refreshToken; + final String tokenType; + final int expiresIn; + final int createdAt; + + AuthTokens({ + required this.accessToken, + required this.refreshToken, + required this.tokenType, + required this.expiresIn, + required this.createdAt, + }); + + factory AuthTokens.fromJson(Map json) { + return AuthTokens( + accessToken: json['access_token'] as String, + refreshToken: json['refresh_token'] as String, + tokenType: json['token_type'] as String, + expiresIn: _parseToInt(json['expires_in']), + createdAt: _parseToInt(json['created_at']), + ); + } + + /// Helper method to parse a value to int, handling both String and int types + static int _parseToInt(dynamic value) { + if (value is int) { + return value; + } else if (value is String) { + return int.parse(value); + } else { + throw FormatException('Cannot parse $value to int'); + } + } + + Map toJson() { + return { + 'access_token': accessToken, + 'refresh_token': refreshToken, + 'token_type': tokenType, + 'expires_in': expiresIn, + 'created_at': createdAt, + }; + } + + bool get isExpired { + final expirationTime = DateTime.fromMillisecondsSinceEpoch( + (createdAt + expiresIn) * 1000, + ); + return DateTime.now().isAfter(expirationTime); + } +} diff --git a/mobile/lib/models/transaction.dart b/mobile/lib/models/transaction.dart new file mode 100644 index 00000000000..8d2e93b3aa3 --- /dev/null +++ b/mobile/lib/models/transaction.dart @@ -0,0 +1,50 @@ +class Transaction { + final String? id; + final String accountId; + final String name; + final String date; + final String amount; + final String currency; + final String nature; // "expense" or "income" + final String? notes; + + Transaction({ + this.id, + required this.accountId, + required this.name, + required this.date, + required this.amount, + required this.currency, + required this.nature, + this.notes, + }); + + factory Transaction.fromJson(Map json) { + return Transaction( + id: json['id']?.toString(), + accountId: json['account_id']?.toString() ?? '', + name: json['name']?.toString() ?? '', + date: json['date']?.toString() ?? '', + amount: json['amount']?.toString() ?? '0', + currency: json['currency']?.toString() ?? '', + nature: json['nature']?.toString() ?? 'expense', + notes: json['notes']?.toString(), + ); + } + + Map toJson() { + return { + if (id != null) 'id': id, + 'account_id': accountId, + 'name': name, + 'date': date, + 'amount': amount, + 'currency': currency, + 'nature': nature, + if (notes != null) 'notes': notes, + }; + } + + bool get isExpense => nature == 'expense'; + bool get isIncome => nature == 'income'; +} diff --git a/mobile/lib/models/user.dart b/mobile/lib/models/user.dart new file mode 100644 index 00000000000..e932bcda31f --- /dev/null +++ b/mobile/lib/models/user.dart @@ -0,0 +1,32 @@ +class User { + final String id; + final String email; + final String? firstName; + final String? lastName; + + User({ + required this.id, + required this.email, + this.firstName, + this.lastName, + }); + + factory User.fromJson(Map json) { + return User( + id: json['id'].toString(), + email: json['email'] as String, + firstName: json['first_name'] as String?, + lastName: json['last_name'] as String?, + ); + } + + String get displayName { + if (firstName != null && lastName != null) { + return '$firstName $lastName'; + } + if (firstName != null) { + return firstName!; + } + return email; + } +} diff --git a/mobile/lib/providers/accounts_provider.dart b/mobile/lib/providers/accounts_provider.dart new file mode 100644 index 00000000000..ebee416bc16 --- /dev/null +++ b/mobile/lib/providers/accounts_provider.dart @@ -0,0 +1,112 @@ +import 'package:flutter/foundation.dart'; +import '../models/account.dart'; +import '../services/accounts_service.dart'; + +class AccountsProvider with ChangeNotifier { + final AccountsService _accountsService = AccountsService(); + + List _accounts = []; + bool _isLoading = false; + String? _errorMessage; + Map? _pagination; + + List get accounts => _accounts; + bool get isLoading => _isLoading; + String? get errorMessage => _errorMessage; + Map? get pagination => _pagination; + + List get assetAccounts { + final assets = _accounts.where((a) => a.isAsset).toList(); + _sortAccounts(assets); + return assets; + } + + List get liabilityAccounts { + final liabilities = _accounts.where((a) => a.isLiability).toList(); + _sortAccounts(liabilities); + return liabilities; + } + + Map get assetTotalsByCurrency { + final totals = {}; + for (var account in _accounts.where((a) => a.isAsset)) { + totals[account.currency] = (totals[account.currency] ?? 0.0) + account.balanceAsDouble; + } + return totals; + } + + Map get liabilityTotalsByCurrency { + final totals = {}; + for (var account in _accounts.where((a) => a.isLiability)) { + totals[account.currency] = (totals[account.currency] ?? 0.0) + account.balanceAsDouble; + } + return totals; + } + + void _sortAccounts(List accounts) { + accounts.sort((a, b) { + // 1. Sort by account type + int typeComparison = a.accountType.compareTo(b.accountType); + if (typeComparison != 0) return typeComparison; + + // 2. Sort by currency + int currencyComparison = a.currency.compareTo(b.currency); + if (currencyComparison != 0) return currencyComparison; + + // 3. Sort by balance (descending - highest first) + int balanceComparison = b.balanceAsDouble.compareTo(a.balanceAsDouble); + if (balanceComparison != 0) return balanceComparison; + + // 4. Sort by name + return a.name.compareTo(b.name); + }); + } + + Future fetchAccounts({ + required String accessToken, + int page = 1, + int perPage = 25, + }) async { + _isLoading = true; + _errorMessage = null; + notifyListeners(); + + try { + final result = await _accountsService.getAccounts( + accessToken: accessToken, + page: page, + perPage: perPage, + ); + + if (result['success'] == true && result.containsKey('accounts')) { + _accounts = (result['accounts'] as List?)?.cast() ?? []; + _pagination = result['pagination'] as Map?; + _isLoading = false; + notifyListeners(); + return true; + } else { + _errorMessage = result['error'] as String? ?? 'Failed to fetch accounts'; + _isLoading = false; + notifyListeners(); + return false; + } + } catch (e) { + _errorMessage = 'Connection error. Please check your internet connection.'; + _isLoading = false; + notifyListeners(); + return false; + } + } + + void clearAccounts() { + _accounts = []; + _pagination = null; + _errorMessage = null; + notifyListeners(); + } + + void clearError() { + _errorMessage = null; + notifyListeners(); + } +} diff --git a/mobile/lib/providers/auth_provider.dart b/mobile/lib/providers/auth_provider.dart new file mode 100644 index 00000000000..24b0a4e6f78 --- /dev/null +++ b/mobile/lib/providers/auth_provider.dart @@ -0,0 +1,213 @@ +import 'package:flutter/foundation.dart'; +import '../models/user.dart'; +import '../models/auth_tokens.dart'; +import '../services/auth_service.dart'; +import '../services/device_service.dart'; + +class AuthProvider with ChangeNotifier { + final AuthService _authService = AuthService(); + final DeviceService _deviceService = DeviceService(); + + User? _user; + AuthTokens? _tokens; + bool _isLoading = true; + bool _isInitializing = true; // Track initial auth check separately + String? _errorMessage; + bool _mfaRequired = false; + bool _showMfaInput = false; // Track if we should show MFA input field + + User? get user => _user; + AuthTokens? get tokens => _tokens; + bool get isLoading => _isLoading; + bool get isInitializing => _isInitializing; // Expose initialization state + bool get isAuthenticated => _tokens != null && !_tokens!.isExpired; + String? get errorMessage => _errorMessage; + bool get mfaRequired => _mfaRequired; + bool get showMfaInput => _showMfaInput; // Expose MFA input state + + AuthProvider() { + _loadStoredAuth(); + } + + Future _loadStoredAuth() async { + _isLoading = true; + _isInitializing = true; + notifyListeners(); + + try { + _tokens = await _authService.getStoredTokens(); + _user = await _authService.getStoredUser(); + + // If tokens exist but are expired, try to refresh + if (_tokens != null && _tokens!.isExpired) { + await _refreshToken(); + } + } catch (e) { + _tokens = null; + _user = null; + } + + _isLoading = false; + _isInitializing = false; + notifyListeners(); + } + + Future login({ + required String email, + required String password, + String? otpCode, + }) async { + _errorMessage = null; + _mfaRequired = false; + _isLoading = true; + // Don't reset _showMfaInput if we're submitting OTP code + if (otpCode == null) { + _showMfaInput = false; + } + notifyListeners(); + + try { + final deviceInfo = await _deviceService.getDeviceInfo(); + final result = await _authService.login( + email: email, + password: password, + deviceInfo: deviceInfo, + otpCode: otpCode, + ); + + debugPrint('Login result: $result'); // Debug log + + if (result['success'] == true) { + _tokens = result['tokens'] as AuthTokens?; + _user = result['user'] as User?; + _mfaRequired = false; + _showMfaInput = false; // Reset on successful login + _isLoading = false; + notifyListeners(); + return true; + } else { + if (result['mfa_required'] == true) { + _mfaRequired = true; + _showMfaInput = true; // Show MFA input field + debugPrint('MFA required! Setting _showMfaInput to true'); // Debug log + + // If user already submitted an OTP code, this is likely an invalid OTP error + // Show the error message so user knows the code was wrong + if (otpCode != null && otpCode.isNotEmpty) { + // Backend returns "Two-factor authentication required" for both cases + // Replace with clearer message when OTP was actually submitted + _errorMessage = 'Invalid authentication code. Please try again.'; + } else { + // First time requesting MFA - don't show error message, it's a normal flow + _errorMessage = null; + } + } else { + _errorMessage = result['error'] as String?; + // If user submitted an OTP (is in MFA flow) but got error, keep MFA input visible + if (otpCode != null) { + _showMfaInput = true; + } + } + _isLoading = false; + notifyListeners(); + return false; + } + } catch (e) { + _errorMessage = 'Connection error: ${e.toString()}'; + _isLoading = false; + notifyListeners(); + return false; + } + } + + Future signup({ + required String email, + required String password, + required String firstName, + required String lastName, + String? inviteCode, + }) async { + _errorMessage = null; + _isLoading = true; + notifyListeners(); + + try { + final deviceInfo = await _deviceService.getDeviceInfo(); + final result = await _authService.signup( + email: email, + password: password, + firstName: firstName, + lastName: lastName, + deviceInfo: deviceInfo, + inviteCode: inviteCode, + ); + + if (result['success'] == true) { + _tokens = result['tokens'] as AuthTokens?; + _user = result['user'] as User?; + _isLoading = false; + notifyListeners(); + return true; + } else { + _errorMessage = result['error'] as String?; + _isLoading = false; + notifyListeners(); + return false; + } + } catch (e) { + _errorMessage = 'Connection error: ${e.toString()}'; + _isLoading = false; + notifyListeners(); + return false; + } + } + + Future logout() async { + await _authService.logout(); + _tokens = null; + _user = null; + _errorMessage = null; + _mfaRequired = false; + notifyListeners(); + } + + Future _refreshToken() async { + if (_tokens == null) return false; + + try { + final deviceInfo = await _deviceService.getDeviceInfo(); + final result = await _authService.refreshToken( + refreshToken: _tokens!.refreshToken, + deviceInfo: deviceInfo, + ); + + if (result['success'] == true) { + _tokens = result['tokens'] as AuthTokens?; + return true; + } else { + // Token refresh failed, clear auth state + await logout(); + return false; + } + } catch (e) { + await logout(); + return false; + } + } + + Future getValidAccessToken() async { + if (_tokens == null) return null; + + if (_tokens!.isExpired) { + final refreshed = await _refreshToken(); + if (!refreshed) return null; + } + + return _tokens?.accessToken; + } + + void clearError() { + _errorMessage = null; + notifyListeners(); + } +} diff --git a/mobile/lib/providers/transactions_provider.dart b/mobile/lib/providers/transactions_provider.dart new file mode 100644 index 00000000000..51006a223b6 --- /dev/null +++ b/mobile/lib/providers/transactions_provider.dart @@ -0,0 +1,88 @@ +import 'dart:collection'; +import 'package:flutter/foundation.dart'; +import '../models/transaction.dart'; +import '../services/transactions_service.dart'; + +class TransactionsProvider with ChangeNotifier { + final TransactionsService _transactionsService = TransactionsService(); + + List _transactions = []; + bool _isLoading = false; + String? _error; + + List get transactions => UnmodifiableListView(_transactions); + bool get isLoading => _isLoading; + String? get error => _error; + + Future fetchTransactions({ + required String accessToken, + String? accountId, + }) async { + _isLoading = true; + _error = null; + notifyListeners(); + + final result = await _transactionsService.getTransactions( + accessToken: accessToken, + accountId: accountId, + ); + + _isLoading = false; + notifyListeners(); + + if (result['success'] == true && result.containsKey('transactions')) { + _transactions = (result['transactions'] as List?)?.cast() ?? []; + _error = null; + } else { + _error = result['error'] as String? ?? 'Failed to fetch transactions'; + } + + notifyListeners(); + } + + Future deleteTransaction({ + required String accessToken, + required String transactionId, + }) async { + final result = await _transactionsService.deleteTransaction( + accessToken: accessToken, + transactionId: transactionId, + ); + + if (result['success'] == true) { + _transactions.removeWhere((t) => t.id == transactionId); + notifyListeners(); + return true; + } else { + _error = result['error'] as String? ?? 'Failed to delete transaction'; + notifyListeners(); + return false; + } + } + + Future deleteMultipleTransactions({ + required String accessToken, + required List transactionIds, + }) async { + final result = await _transactionsService.deleteMultipleTransactions( + accessToken: accessToken, + transactionIds: transactionIds, + ); + + if (result['success'] == true) { + _transactions.removeWhere((t) => transactionIds.contains(t.id)); + notifyListeners(); + return true; + } else { + _error = result['error'] as String? ?? 'Failed to delete transactions'; + notifyListeners(); + return false; + } + } + + void clearTransactions() { + _transactions = []; + _error = null; + notifyListeners(); + } +} diff --git a/mobile/lib/screens/backend_config_screen.dart b/mobile/lib/screens/backend_config_screen.dart new file mode 100644 index 00000000000..c97f9ae0e20 --- /dev/null +++ b/mobile/lib/screens/backend_config_screen.dart @@ -0,0 +1,358 @@ +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:http/http.dart' as http; +import '../services/api_config.dart'; + +class BackendConfigScreen extends StatefulWidget { + final VoidCallback? onConfigSaved; + + const BackendConfigScreen({super.key, this.onConfigSaved}); + + @override + State createState() => _BackendConfigScreenState(); +} + +class _BackendConfigScreenState extends State { + final _formKey = GlobalKey(); + final _urlController = TextEditingController(); + bool _isLoading = false; + bool _isTesting = false; + String? _errorMessage; + String? _successMessage; + + @override + void initState() { + super.initState(); + _loadSavedUrl(); + } + + @override + void dispose() { + _urlController.dispose(); + super.dispose(); + } + + Future _loadSavedUrl() async { + final prefs = await SharedPreferences.getInstance(); + final savedUrl = prefs.getString('backend_url'); + if (savedUrl != null && savedUrl.isNotEmpty) { + setState(() { + _urlController.text = savedUrl; + }); + } + } + + Future _testConnection() async { + if (!_formKey.currentState!.validate()) return; + + setState(() { + _isTesting = true; + _errorMessage = null; + _successMessage = null; + }); + + try { + final url = _urlController.text.trim(); + + // Check /sessions/new page to verify it's a Sure backend + final sessionsUrl = Uri.parse('$url/sessions/new'); + final sessionsResponse = await http.get( + sessionsUrl, + headers: {'Accept': 'text/html'}, + ).timeout( + const Duration(seconds: 10), + onTimeout: () { + throw Exception('Connection timeout. Please check the URL and try again.'); + }, + ); + + if (sessionsResponse.statusCode >= 200 && sessionsResponse.statusCode < 400) { + setState(() { + _successMessage = 'Connection successful! Sure backend is reachable.'; + }); + } else { + setState(() { + _errorMessage = 'Server responded with status ${sessionsResponse.statusCode}. Please check if this is a Sure backend server.'; + }); + } + } catch (e) { + setState(() { + _errorMessage = 'Connection failed: ${e.toString()}'; + }); + } finally { + setState(() { + _isTesting = false; + }); + } + } + + Future _saveAndContinue() async { + if (!_formKey.currentState!.validate()) return; + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final url = _urlController.text.trim(); + + // Save URL to SharedPreferences + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('backend_url', url); + + // Update ApiConfig + ApiConfig.setBaseUrl(url); + + // Notify parent that config is saved + if (mounted && widget.onConfigSaved != null) { + widget.onConfigSaved!(); + } + } catch (e) { + setState(() { + _errorMessage = 'Failed to save URL: ${e.toString()}'; + }); + } finally { + setState(() { + _isLoading = false; + }); + } + } + + String? _validateUrl(String? value) { + if (value == null || value.isEmpty) { + return 'Please enter a backend URL'; + } + + final trimmedValue = value.trim(); + + // Check if it starts with http:// or https:// + if (!trimmedValue.startsWith('http://') && !trimmedValue.startsWith('https://')) { + return 'URL must start with http:// or https://'; + } + + // Basic URL validation + try { + final uri = Uri.parse(trimmedValue); + if (!uri.hasScheme || uri.host.isEmpty) { + return 'Please enter a valid URL'; + } + } catch (e) { + return 'Please enter a valid URL'; + } + + return null; + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Scaffold( + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 48), + // Logo/Title + Icon( + Icons.settings_outlined, + size: 80, + color: colorScheme.primary, + ), + const SizedBox(height: 16), + Text( + 'Backend Configuration', + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + color: colorScheme.primary, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'Enter your Sure Finance backend URL', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 48), + + // Info box + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colorScheme.primaryContainer.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.info_outline, + color: colorScheme.primary, + ), + const SizedBox(width: 12), + Text( + 'Example URLs', + style: TextStyle( + color: colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + '• https://sure.lazyrhythm.com\n' + '• https://your-domain.com\n' + '• http://localhost:3000', + style: TextStyle( + color: colorScheme.onSurface, + fontFamily: 'monospace', + ), + ), + ], + ), + ), + const SizedBox(height: 24), + + // Error Message + if (_errorMessage != null) + Container( + padding: const EdgeInsets.all(12), + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: colorScheme.errorContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon( + Icons.error_outline, + color: colorScheme.error, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + _errorMessage!, + style: TextStyle(color: colorScheme.onErrorContainer), + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () { + setState(() { + _errorMessage = null; + }); + }, + iconSize: 20, + ), + ], + ), + ), + + // Success Message + if (_successMessage != null) + Container( + padding: const EdgeInsets.all(12), + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Colors.green.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.green), + ), + child: Row( + children: [ + const Icon( + Icons.check_circle_outline, + color: Colors.green, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + _successMessage!, + style: TextStyle(color: Colors.green[800]), + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () { + setState(() { + _successMessage = null; + }); + }, + iconSize: 20, + ), + ], + ), + ), + + // URL Field + TextFormField( + controller: _urlController, + keyboardType: TextInputType.url, + autocorrect: false, + textInputAction: TextInputAction.done, + decoration: const InputDecoration( + labelText: 'Backend URL', + prefixIcon: Icon(Icons.cloud_outlined), + hintText: 'https://sure.lazyrhythm.com', + ), + validator: _validateUrl, + onFieldSubmitted: (_) => _saveAndContinue(), + ), + const SizedBox(height: 16), + + // Test Connection Button + OutlinedButton.icon( + onPressed: _isTesting || _isLoading ? null : _testConnection, + icon: _isTesting + ? const SizedBox( + height: 16, + width: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.cable), + label: Text(_isTesting ? 'Testing...' : 'Test Connection'), + ), + + const SizedBox(height: 12), + + // Continue Button + ElevatedButton( + onPressed: _isLoading || _isTesting ? null : _saveAndContinue, + child: _isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Continue'), + ), + + const SizedBox(height: 24), + + // Info text + Text( + 'You can change this later in the settings.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/mobile/lib/screens/dashboard_screen.dart b/mobile/lib/screens/dashboard_screen.dart new file mode 100644 index 00000000000..94533c7a53c --- /dev/null +++ b/mobile/lib/screens/dashboard_screen.dart @@ -0,0 +1,733 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../models/account.dart'; +import '../providers/auth_provider.dart'; +import '../providers/accounts_provider.dart'; +import '../widgets/account_card.dart'; +import 'transaction_form_screen.dart'; +import 'transactions_list_screen.dart'; + +class DashboardScreen extends StatefulWidget { + const DashboardScreen({super.key}); + + @override + State createState() => _DashboardScreenState(); +} + +class _DashboardScreenState extends State { + bool _assetsExpanded = true; + bool _liabilitiesExpanded = true; + + @override + void initState() { + super.initState(); + _loadAccounts(); + } + + Future _loadAccounts() async { + final authProvider = Provider.of(context, listen: false); + final accountsProvider = Provider.of(context, listen: false); + + final accessToken = await authProvider.getValidAccessToken(); + if (accessToken == null) { + // Token is invalid, redirect to login + await authProvider.logout(); + return; + } + + await accountsProvider.fetchAccounts(accessToken: accessToken); + + // Check if unauthorized + if (accountsProvider.errorMessage == 'unauthorized') { + await authProvider.logout(); + } + } + + Future _handleRefresh() async { + await _loadAccounts(); + } + + List _formatCurrencyItem(String currency, double amount) { + final symbol = _getCurrencySymbol(currency); + final isSmallAmount = amount.abs() < 1 && amount != 0; + final formattedAmount = amount.toStringAsFixed(isSmallAmount ? 4 : 0); + + // Split into integer and decimal parts + final parts = formattedAmount.split('.'); + final integerPart = parts[0].replaceAllMapped( + RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), + (Match m) => '${m[1]},', + ); + + final finalAmount = parts.length > 1 ? '$integerPart.${parts[1]}' : integerPart; + return [currency, '$symbol$finalAmount']; + } + + String _getCurrencySymbol(String currency) { + switch (currency.toUpperCase()) { + case 'USD': + return '\$'; + case 'TWD': + return '\$'; + case 'BTC': + return '₿'; + case 'ETH': + return 'Ξ'; + case 'EUR': + return '€'; + case 'GBP': + return '£'; + case 'JPY': + return '¥'; + case 'CNY': + return '¥'; + default: + return ' '; + } + } + + Future _handleAccountTap(Account account) async { + final result = await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => TransactionFormScreen(account: account), + ); + + // Refresh accounts if transaction was created successfully + if (result == true && mounted) { + // Show loading indicator + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Row( + children: [ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ), + SizedBox(width: 12), + Text('Refreshing accounts...'), + ], + ), + duration: Duration(seconds: 2), + ), + ); + + // Small delay to ensure smooth UI transition + await Future.delayed(const Duration(milliseconds: 50)); + + // Refresh the accounts + await _loadAccounts(); + + // Hide loading snackbar and show success + if (mounted) { + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Row( + children: [ + Icon(Icons.check_circle, color: Colors.white), + SizedBox(width: 12), + Text('Accounts updated'), + ], + ), + backgroundColor: Colors.green, + duration: Duration(seconds: 1), + ), + ); + } + } + } + + Future _handleAccountSwipe(Account account) async { + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => TransactionsListScreen(account: account), + ), + ); + + // Refresh accounts when returning from transaction list + if (mounted) { + await _loadAccounts(); + } + } + + Future _handleLogout() async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Sign Out'), + content: const Text('Are you sure you want to sign out?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Sign Out'), + ), + ], + ), + ); + + if (confirmed == true && mounted) { + final authProvider = Provider.of(context, listen: false); + final accountsProvider = Provider.of(context, listen: false); + + accountsProvider.clearAccounts(); + await authProvider.logout(); + } + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Scaffold( + appBar: AppBar( + title: const Text('Dashboard'), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: _handleRefresh, + tooltip: 'Refresh', + ), + IconButton( + icon: const Icon(Icons.logout), + onPressed: _handleLogout, + tooltip: 'Sign Out', + ), + ], + ), + body: Consumer2( + builder: (context, authProvider, accountsProvider, _) { + // Show loading state + if (accountsProvider.isLoading) { + return const Center( + child: CircularProgressIndicator(), + ); + } + + // Show error state + if (accountsProvider.errorMessage != null && + accountsProvider.errorMessage != 'unauthorized') { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + size: 64, + color: colorScheme.error, + ), + const SizedBox(height: 16), + Text( + 'Failed to load accounts', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8), + Text( + accountsProvider.errorMessage!, + style: TextStyle(color: colorScheme.onSurfaceVariant), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + ElevatedButton.icon( + onPressed: _handleRefresh, + icon: const Icon(Icons.refresh), + label: const Text('Try Again'), + ), + ], + ), + ), + ); + } + + // Show empty state + if (accountsProvider.accounts.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.account_balance_wallet_outlined, + size: 64, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 16), + Text( + 'No accounts yet', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8), + Text( + 'Add accounts in the web app to see them here.', + style: TextStyle(color: colorScheme.onSurfaceVariant), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + ElevatedButton.icon( + onPressed: _handleRefresh, + icon: const Icon(Icons.refresh), + label: const Text('Refresh'), + ), + ], + ), + ), + ); + } + + // Show accounts list + return RefreshIndicator( + onRefresh: _handleRefresh, + child: CustomScrollView( + slivers: [ + // Welcome header + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Welcome${authProvider.user != null ? ', ${authProvider.user!.displayName}' : ''}', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + 'Here\'s your financial overview', + style: TextStyle(color: colorScheme.onSurfaceVariant), + ), + ], + ), + ), + ), + + // Summary cards + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + children: [ + if (accountsProvider.assetAccounts.isNotEmpty) + _SummaryCard( + title: 'Assets Total', + totals: accountsProvider.assetTotalsByCurrency, + color: Colors.green, + formatCurrencyItem: _formatCurrencyItem, + ), + if (accountsProvider.liabilityAccounts.isNotEmpty) + _SummaryCard( + title: 'Liabilities Total', + totals: accountsProvider.liabilityTotalsByCurrency, + color: Colors.red, + formatCurrencyItem: _formatCurrencyItem, + ), + ], + ), + ), + ), + + // Assets section + if (accountsProvider.assetAccounts.isNotEmpty) ...[ + SliverToBoxAdapter( + child: _CollapsibleSectionHeader( + title: 'Assets', + count: accountsProvider.assetAccounts.length, + color: Colors.green, + isExpanded: _assetsExpanded, + onToggle: () { + setState(() { + _assetsExpanded = !_assetsExpanded; + }); + }, + ), + ), + if (_assetsExpanded) + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final account = accountsProvider.assetAccounts[index]; + return AccountCard( + account: account, + onTap: () => _handleAccountTap(account), + onSwipe: () => _handleAccountSwipe(account), + ); + }, + childCount: accountsProvider.assetAccounts.length, + ), + ), + ), + ], + + // Liabilities section + if (accountsProvider.liabilityAccounts.isNotEmpty) ...[ + SliverToBoxAdapter( + child: _CollapsibleSectionHeader( + title: 'Liabilities', + count: accountsProvider.liabilityAccounts.length, + color: Colors.red, + isExpanded: _liabilitiesExpanded, + onToggle: () { + setState(() { + _liabilitiesExpanded = !_liabilitiesExpanded; + }); + }, + ), + ), + if (_liabilitiesExpanded) + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final account = accountsProvider.liabilityAccounts[index]; + return AccountCard( + account: account, + onTap: () => _handleAccountTap(account), + onSwipe: () => _handleAccountSwipe(account), + ); + }, + childCount: accountsProvider.liabilityAccounts.length, + ), + ), + ), + ], + + // Uncategorized accounts + ..._buildUncategorizedSection(accountsProvider), + + // Bottom padding + const SliverToBoxAdapter( + child: SizedBox(height: 24), + ), + ], + ), + ); + }, + ), + ); + } + + List _buildUncategorizedSection(AccountsProvider accountsProvider) { + final uncategorized = accountsProvider.accounts + .where((a) => !a.isAsset && !a.isLiability) + .toList(); + + if (uncategorized.isEmpty) { + return []; + } + + return [ + SliverToBoxAdapter( + child: _SimpleSectionHeader( + title: 'Other Accounts', + count: uncategorized.length, + color: Colors.grey, + ), + ), + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final account = uncategorized[index]; + return AccountCard( + account: account, + onTap: () => _handleAccountTap(account), + onSwipe: () => _handleAccountSwipe(account), + ); + }, + childCount: uncategorized.length, + ), + ), + ), + ]; + } +} + +class _SummaryCard extends StatelessWidget { + final String title; + final Map totals; + final Color color; + final List Function(String currency, double amount) formatCurrencyItem; + + const _SummaryCard({ + required this.title, + required this.totals, + required this.color, + required this.formatCurrencyItem, + }); + + @override + Widget build(BuildContext context) { + final entries = totals.entries.toList(); + final rows = []; + + // Group currencies into pairs (2 per row) + for (int i = 0; i < entries.length; i += 2) { + final first = entries[i]; + final firstFormatted = formatCurrencyItem(first.key, first.value); + + if (i + 1 < entries.length) { + // Two items in this row + final second = entries[i + 1]; + final secondFormatted = formatCurrencyItem(second.key, second.value); + + rows.add( + Row( + children: [ + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + firstFormatted[0], + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + Text( + firstFormatted[1], + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + Text( + ' | ', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w300, + color: color.withValues(alpha: 0.5), + ), + ), + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + secondFormatted[0], + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + Text( + secondFormatted[1], + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ], + ), + ); + } else { + // Only one item in this row + rows.add( + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + firstFormatted[0], + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + Text( + firstFormatted[1], + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + } + + if (i + 2 < entries.length) { + rows.add(const SizedBox(height: 4)); + } + } + + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: color.withValues(alpha: 0.3), + width: 1, + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 4, + height: 40, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: color, + ), + ), + const SizedBox(height: 8), + ...rows, + ], + ), + ), + ], + ), + ); + } +} + +class _CollapsibleSectionHeader extends StatelessWidget { + final String title; + final int count; + final Color color; + final bool isExpanded; + final VoidCallback onToggle; + + const _CollapsibleSectionHeader({ + required this.title, + required this.count, + required this.color, + required this.isExpanded, + required this.onToggle, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onToggle, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 24, 16, 8), + child: Row( + children: [ + Container( + width: 4, + height: 24, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 12), + Text( + title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + count.toString(), + style: TextStyle( + color: color, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ), + const Spacer(), + Icon( + isExpanded ? Icons.expand_less : Icons.expand_more, + color: color, + ), + ], + ), + ), + ); + } +} + +class _SimpleSectionHeader extends StatelessWidget { + final String title; + final int count; + final Color color; + + const _SimpleSectionHeader({ + required this.title, + required this.count, + required this.color, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 24, 16, 8), + child: Row( + children: [ + Container( + width: 4, + height: 24, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 12), + Text( + title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + count.toString(), + style: TextStyle( + color: color, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/screens/login_screen.dart b/mobile/lib/screens/login_screen.dart new file mode 100644 index 00000000000..c524bada741 --- /dev/null +++ b/mobile/lib/screens/login_screen.dart @@ -0,0 +1,311 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../providers/auth_provider.dart'; +import '../services/api_config.dart'; + +class LoginScreen extends StatefulWidget { + final VoidCallback? onGoToSettings; + + const LoginScreen({super.key, this.onGoToSettings}); + + @override + State createState() => _LoginScreenState(); +} + +class _LoginScreenState extends State { + final _formKey = GlobalKey(); + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + final _otpController = TextEditingController(); + bool _obscurePassword = true; + + @override + void dispose() { + _emailController.dispose(); + _passwordController.dispose(); + _otpController.dispose(); + super.dispose(); + } + + Future _handleLogin() async { + if (!_formKey.currentState!.validate()) return; + + final authProvider = Provider.of(context, listen: false); + final hadOtpCode = authProvider.showMfaInput && _otpController.text.isNotEmpty; + + final success = await authProvider.login( + email: _emailController.text.trim(), + password: _passwordController.text, + otpCode: authProvider.showMfaInput ? _otpController.text.trim() : null, + ); + + // Check if widget is still mounted after async operation + if (!mounted) return; + + // Clear OTP field if login failed and user had entered an OTP code + // This allows user to easily try again with a new code + if (!success && hadOtpCode && authProvider.errorMessage != null) { + _otpController.clear(); + } + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Scaffold( + appBar: AppBar( + title: const Text(''), + actions: [ + IconButton( + icon: const Icon(Icons.settings_outlined), + tooltip: 'Backend Settings', + onPressed: widget.onGoToSettings, + ), + ], + ), + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 48), + // Logo/Title + Icon( + Icons.account_balance_wallet, + size: 80, + color: colorScheme.primary, + ), + const SizedBox(height: 16), + Text( + 'Sure Finance', + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + color: colorScheme.primary, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'Sign in to manage your finances', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 48), + + // Error Message + Consumer( + builder: (context, authProvider, _) { + if (authProvider.errorMessage != null) { + return Container( + padding: const EdgeInsets.all(12), + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: colorScheme.errorContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon( + Icons.error_outline, + color: colorScheme.error, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + authProvider.errorMessage!, + style: TextStyle(color: colorScheme.onErrorContainer), + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => authProvider.clearError(), + iconSize: 20, + ), + ], + ), + ); + } + return const SizedBox.shrink(); + }, + ), + + // Email Field + TextFormField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + autocorrect: false, + textInputAction: TextInputAction.next, + decoration: const InputDecoration( + labelText: 'Email', + prefixIcon: Icon(Icons.email_outlined), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter your email'; + } + if (!value.contains('@')) { + return 'Please enter a valid email'; + } + return null; + }, + ), + const SizedBox(height: 16), + + // Password and OTP Fields with Consumer + Consumer( + builder: (context, authProvider, _) { + final showOtp = authProvider.showMfaInput; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Password Field + TextFormField( + controller: _passwordController, + obscureText: _obscurePassword, + textInputAction: showOtp + ? TextInputAction.next + : TextInputAction.done, + decoration: InputDecoration( + labelText: 'Password', + prefixIcon: const Icon(Icons.lock_outlined), + suffixIcon: IconButton( + icon: Icon( + _obscurePassword + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + ), + onPressed: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + ), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter your password'; + } + return null; + }, + onFieldSubmitted: showOtp ? null : (_) => _handleLogin(), + ), + + // OTP Field (shown when MFA is required) + if (showOtp) ...[ + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.primaryContainer.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon( + Icons.security, + color: colorScheme.primary, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Two-factor authentication is enabled. Enter your code.', + style: TextStyle(color: colorScheme.onSurface), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + TextFormField( + controller: _otpController, + keyboardType: TextInputType.number, + textInputAction: TextInputAction.done, + decoration: const InputDecoration( + labelText: 'Authentication Code', + prefixIcon: Icon(Icons.pin_outlined), + ), + validator: (value) { + if (showOtp && (value == null || value.isEmpty)) { + return 'Please enter your authentication code'; + } + return null; + }, + onFieldSubmitted: (_) => _handleLogin(), + ), + ], + ], + ); + }, + ), + + const SizedBox(height: 24), + + // Login Button + Consumer( + builder: (context, authProvider, _) { + return ElevatedButton( + onPressed: authProvider.isLoading ? null : _handleLogin, + child: authProvider.isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Sign In'), + ); + }, + ), + + const SizedBox(height: 24), + + // Backend URL info + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + children: [ + Text( + 'Backend URL:', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + ApiConfig.baseUrl, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.primary, + fontFamily: 'monospace', + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + const SizedBox(height: 8), + Text( + 'Connect to your Sure Finance server to manage your accounts.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/mobile/lib/screens/transaction_form_screen.dart b/mobile/lib/screens/transaction_form_screen.dart new file mode 100644 index 00000000000..82966fee011 --- /dev/null +++ b/mobile/lib/screens/transaction_form_screen.dart @@ -0,0 +1,374 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:intl/intl.dart'; +import '../models/account.dart'; +import '../providers/auth_provider.dart'; +import '../services/transactions_service.dart'; + +class TransactionFormScreen extends StatefulWidget { + final Account account; + + const TransactionFormScreen({ + super.key, + required this.account, + }); + + @override + State createState() => _TransactionFormScreenState(); +} + +class _TransactionFormScreenState extends State { + final _formKey = GlobalKey(); + final _amountController = TextEditingController(); + final _dateController = TextEditingController(); + final _nameController = TextEditingController(); + final _transactionsService = TransactionsService(); + + String _nature = 'expense'; + bool _showMoreFields = false; + bool _isSubmitting = false; + + @override + void initState() { + super.initState(); + // Set default values + final now = DateTime.now(); + final formattedDate = DateFormat('yyyy/MM/dd').format(now); + _dateController.text = formattedDate; + _nameController.text = 'SureApp'; + } + + @override + void dispose() { + _amountController.dispose(); + _dateController.dispose(); + _nameController.dispose(); + super.dispose(); + } + + String? _validateAmount(String? value) { + if (value == null || value.trim().isEmpty) { + return 'Please enter an amount'; + } + + final amount = double.tryParse(value.trim()); + if (amount == null) { + return 'Please enter a valid number'; + } + + if (amount <= 0) { + return 'Amount must be greater than 0'; + } + + return null; + } + + Future _selectDate() async { + final DateTime? picked = await showDatePicker( + context: context, + initialDate: DateTime.now(), + firstDate: DateTime(2000), + lastDate: DateTime(2100), + ); + + if (picked != null) { + setState(() { + _dateController.text = DateFormat('yyyy/MM/dd').format(picked); + }); + } + } + + Future _handleSubmit() async { + if (!_formKey.currentState!.validate()) { + return; + } + + setState(() { + _isSubmitting = true; + }); + + try { + final authProvider = Provider.of(context, listen: false); + final accessToken = await authProvider.getValidAccessToken(); + + if (accessToken == null) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Session expired. Please login again.'), + backgroundColor: Colors.red, + ), + ); + await authProvider.logout(); + } + return; + } + + // Convert date format from yyyy/MM/dd to yyyy-MM-dd + final dateParts = _dateController.text.split('/'); + final apiDate = '${dateParts[0]}-${dateParts[1]}-${dateParts[2]}'; + + final result = await _transactionsService.createTransaction( + accessToken: accessToken, + accountId: widget.account.id, + name: _nameController.text.trim(), + date: apiDate, + amount: _amountController.text.trim(), + currency: widget.account.currency, + nature: _nature, + notes: 'This transaction via mobile app.', + ); + + if (mounted) { + if (result['success'] == true) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Transaction created successfully'), + backgroundColor: Colors.green, + ), + ); + Navigator.pop(context, true); // Return true to indicate success + } else { + final error = result['error'] ?? 'Failed to create transaction'; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(error), + backgroundColor: Colors.red, + ), + ); + + if (error == 'unauthorized') { + await authProvider.logout(); + } + } + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error: ${e.toString()}'), + backgroundColor: Colors.red, + ), + ); + } + } finally { + if (mounted) { + setState(() { + _isSubmitting = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Container( + decoration: BoxDecoration( + color: Theme.of(context).scaffoldBackgroundColor, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(20), + topRight: Radius.circular(20), + ), + ), + child: DraggableScrollableSheet( + initialChildSize: 0.9, + minChildSize: 0.5, + maxChildSize: 0.95, + expand: false, + builder: (context, scrollController) { + return Column( + children: [ + // Handle bar + Container( + margin: const EdgeInsets.only(top: 12, bottom: 8), + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(2), + ), + ), + // Title + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'New Transaction', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ), + const Divider(height: 1), + // Form content + Expanded( + child: SingleChildScrollView( + controller: scrollController, + padding: EdgeInsets.only( + left: 24, + right: 24, + top: 16, + bottom: MediaQuery.of(context).viewInsets.bottom + 24, + ), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Account info card + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Icon( + Icons.account_balance_wallet, + color: colorScheme.primary, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.account.name, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + '${widget.account.balance} ${widget.account.currency}', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + ), + ), + const SizedBox(height: 24), + + // Transaction type selection + Text( + 'Type', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + SegmentedButton( + segments: const [ + ButtonSegment( + value: 'expense', + label: Text('Expense'), + icon: Icon(Icons.arrow_downward), + ), + ButtonSegment( + value: 'income', + label: Text('Income'), + icon: Icon(Icons.arrow_upward), + ), + ], + selected: {_nature}, + onSelectionChanged: (Set newSelection) { + setState(() { + _nature = newSelection.first; + }); + }, + ), + const SizedBox(height: 24), + + // Amount field + TextFormField( + controller: _amountController, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: InputDecoration( + labelText: 'Amount *', + prefixIcon: const Icon(Icons.attach_money), + suffixText: widget.account.currency, + helperText: 'Required', + ), + validator: _validateAmount, + ), + const SizedBox(height: 24), + + // More button + TextButton.icon( + onPressed: () { + setState(() { + _showMoreFields = !_showMoreFields; + }); + }, + icon: Icon(_showMoreFields ? Icons.expand_less : Icons.expand_more), + label: Text(_showMoreFields ? 'Less' : 'More'), + ), + + // Optional fields (shown when More is clicked) + if (_showMoreFields) ...[ + const SizedBox(height: 16), + + // Date field + TextFormField( + controller: _dateController, + readOnly: true, + decoration: const InputDecoration( + labelText: 'Date', + prefixIcon: Icon(Icons.calendar_today), + helperText: 'Optional (default: today)', + ), + onTap: _selectDate, + ), + const SizedBox(height: 16), + + // Name field + TextFormField( + controller: _nameController, + decoration: const InputDecoration( + labelText: 'Name', + prefixIcon: Icon(Icons.label), + helperText: 'Optional (default: SureApp)', + ), + ), + ], + + const SizedBox(height: 32), + + // Submit button + ElevatedButton( + onPressed: _isSubmitting ? null : _handleSubmit, + child: _isSubmitting + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const Text('Create Transaction'), + ), + ], + ), + ), + ), + ), + ], + ); + }, + ), + ); + } +} diff --git a/mobile/lib/screens/transactions_list_screen.dart b/mobile/lib/screens/transactions_list_screen.dart new file mode 100644 index 00000000000..e89b7011dd1 --- /dev/null +++ b/mobile/lib/screens/transactions_list_screen.dart @@ -0,0 +1,409 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../models/account.dart'; +import '../providers/auth_provider.dart'; +import '../providers/transactions_provider.dart'; +import '../screens/transaction_form_screen.dart'; + +class TransactionsListScreen extends StatefulWidget { + final Account account; + + const TransactionsListScreen({ + super.key, + required this.account, + }); + + @override + State createState() => _TransactionsListScreenState(); +} + +class _TransactionsListScreenState extends State { + bool _isSelectionMode = false; + final Set _selectedTransactions = {}; + + @override + void initState() { + super.initState(); + _loadTransactions(); + } + + // 計算負號個數並決定顯示邏輯 + Map _getAmountDisplayInfo(String amount, bool isAsset) { + // 計算負號個數 + int negativeCount = '-'.allMatches(amount).length; + + // Asset 帳戶需要在負號個數上 +1 進行微調 + if (isAsset) { + negativeCount += 1; + } + + // 移除所有負號以獲取純數字 + String cleanAmount = amount.replaceAll('-', ''); + + // 偶數個負號 = 正數,奇數個負號 = 負數 + bool isPositive = negativeCount % 2 == 0; + + return { + 'isPositive': isPositive, + 'displayAmount': cleanAmount, + 'color': isPositive ? Colors.green : Colors.red, + 'icon': isPositive ? Icons.arrow_upward : Icons.arrow_downward, + 'prefix': isPositive ? '' : '-', + }; + } + + Future _loadTransactions() async { + final authProvider = Provider.of(context, listen: false); + final transactionsProvider = Provider.of(context, listen: false); + + final accessToken = await authProvider.getValidAccessToken(); + if (accessToken != null) { + await transactionsProvider.fetchTransactions( + accessToken: accessToken, + accountId: widget.account.id, + ); + } + } + + void _toggleSelectionMode() { + setState(() { + _isSelectionMode = !_isSelectionMode; + if (!_isSelectionMode) { + _selectedTransactions.clear(); + } + }); + } + + void _toggleTransactionSelection(String transactionId) { + setState(() { + if (_selectedTransactions.contains(transactionId)) { + _selectedTransactions.remove(transactionId); + } else { + _selectedTransactions.add(transactionId); + } + }); + } + + Future _deleteSelectedTransactions() async { + if (_selectedTransactions.isEmpty) return; + + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete Transactions'), + content: Text('Are you sure you want to delete ${_selectedTransactions.length} transaction(s)?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Delete'), + ), + ], + ), + ); + + if (confirmed != true || !mounted) return; + + final authProvider = Provider.of(context, listen: false); + final transactionsProvider = Provider.of(context, listen: false); + + final accessToken = await authProvider.getValidAccessToken(); + if (accessToken != null) { + final success = await transactionsProvider.deleteMultipleTransactions( + accessToken: accessToken, + transactionIds: _selectedTransactions.toList(), + ); + + if (mounted) { + if (success) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Deleted ${_selectedTransactions.length} transaction(s)'), + backgroundColor: Colors.green, + ), + ); + setState(() { + _selectedTransactions.clear(); + _isSelectionMode = false; + }); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Failed to delete transactions'), + backgroundColor: Colors.red, + ), + ); + } + } + } + } + + + void _showAddTransactionForm() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => TransactionFormScreen(account: widget.account), + ).then((_) { + _loadTransactions(); + }); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Scaffold( + appBar: AppBar( + title: Text(widget.account.name), + actions: [ + if (_isSelectionMode) + IconButton( + icon: const Icon(Icons.delete), + onPressed: _selectedTransactions.isEmpty ? null : _deleteSelectedTransactions, + ), + IconButton( + icon: Icon(_isSelectionMode ? Icons.close : Icons.checklist), + onPressed: _toggleSelectionMode, + ), + ], + ), + body: Consumer( + builder: (context, transactionsProvider, child) { + if (transactionsProvider.isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (transactionsProvider.error != null) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, size: 48, color: Colors.red), + const SizedBox(height: 16), + Text( + transactionsProvider.error!, + style: const TextStyle(color: Colors.red), + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadTransactions, + child: const Text('Retry'), + ), + ], + ), + ); + } + + final transactions = transactionsProvider.transactions; + + if (transactions.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.receipt_long_outlined, + size: 64, + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5), + ), + const SizedBox(height: 16), + Text( + 'No transactions yet', + style: TextStyle( + fontSize: 16, + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Text( + 'Tap + to add your first transaction', + style: TextStyle( + fontSize: 14, + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7), + ), + ), + ], + ), + ); + } + + return RefreshIndicator( + onRefresh: _loadTransactions, + child: ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: transactions.length, + itemBuilder: (context, index) { + final transaction = transactions[index]; + final isSelected = transaction.id != null && + _selectedTransactions.contains(transaction.id); + + return Dismissible( + key: Key(transaction.id ?? 'transaction_$index'), + direction: _isSelectionMode + ? DismissDirection.none + : DismissDirection.endToStart, + background: Container( + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 20), + decoration: BoxDecoration( + color: Colors.red, + borderRadius: BorderRadius.circular(12), + ), + child: const Icon(Icons.delete, color: Colors.white), + ), + confirmDismiss: (direction) async { + if (transaction.id == null) return false; + return await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete Transaction'), + content: Text('Are you sure you want to delete "${transaction.name}"?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Delete'), + ), + ], + ), + ); + }, + onDismissed: (direction) async { + if (transaction.id != null) { + final scaffoldMessenger = ScaffoldMessenger.of(context); + final authProvider = Provider.of(context, listen: false); + final accessToken = await authProvider.getValidAccessToken(); + if (accessToken != null) { + final success = await transactionsProvider.deleteTransaction( + accessToken: accessToken, + transactionId: transaction.id!, + ); + + if (mounted && success) { + scaffoldMessenger.showSnackBar( + const SnackBar( + content: Text('Transaction deleted'), + backgroundColor: Colors.green, + ), + ); + } + } + } + }, + child: Card( + margin: const EdgeInsets.only(bottom: 12), + child: InkWell( + onTap: _isSelectionMode && transaction.id != null + ? () => _toggleTransactionSelection(transaction.id!) + : null, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + if (_isSelectionMode) + Padding( + padding: const EdgeInsets.only(right: 12), + child: Checkbox( + value: isSelected, + onChanged: transaction.id != null + ? (value) => _toggleTransactionSelection(transaction.id!) + : null, + ), + ), + Builder( + builder: (context) { + final displayInfo = _getAmountDisplayInfo( + transaction.amount, + widget.account.isAsset, + ); + return Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: (displayInfo['color'] as Color).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + displayInfo['icon'] as IconData, + color: displayInfo['color'] as Color, + ), + ); + }, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + transaction.name, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + transaction.date, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + Builder( + builder: (context) { + final displayInfo = _getAmountDisplayInfo( + transaction.amount, + widget.account.isAsset, + ); + return Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '${displayInfo['prefix']}${displayInfo['displayAmount']}', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: displayInfo['color'] as Color, + ), + ), + const SizedBox(height: 4), + Text( + transaction.currency, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ); + }, + ), + ], + ), + ), + ), + ), + ); + }, + ), + ); + }, + ), + floatingActionButton: FloatingActionButton( + onPressed: _showAddTransactionForm, + child: const Icon(Icons.add), + ), + ); + } +} diff --git a/mobile/lib/services/accounts_service.dart b/mobile/lib/services/accounts_service.dart new file mode 100644 index 00000000000..1f5af6e82dc --- /dev/null +++ b/mobile/lib/services/accounts_service.dart @@ -0,0 +1,57 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import '../models/account.dart'; +import 'api_config.dart'; + +class AccountsService { + Future> getAccounts({ + required String accessToken, + int page = 1, + int perPage = 25, + }) async { + try { + final url = Uri.parse( + '${ApiConfig.baseUrl}/api/v1/accounts?page=$page&per_page=$perPage', + ); + + final response = await http.get( + url, + headers: { + 'Authorization': 'Bearer $accessToken', + 'Accept': 'application/json', + }, + ).timeout(const Duration(seconds: 30)); + + if (response.statusCode == 200) { + final responseData = jsonDecode(response.body); + + final accountsList = (responseData['accounts'] as List) + .map((json) => Account.fromJson(json)) + .toList(); + + return { + 'success': true, + 'accounts': accountsList, + 'pagination': responseData['pagination'], + }; + } else if (response.statusCode == 401) { + return { + 'success': false, + 'error': 'unauthorized', + 'message': 'Session expired. Please login again.', + }; + } else { + final responseData = jsonDecode(response.body); + return { + 'success': false, + 'error': responseData['error'] ?? 'Failed to fetch accounts', + }; + } + } catch (e) { + return { + 'success': false, + 'error': 'Network error: ${e.toString()}', + }; + } + } +} diff --git a/mobile/lib/services/api_config.dart b/mobile/lib/services/api_config.dart new file mode 100644 index 00000000000..09e4db7f8e7 --- /dev/null +++ b/mobile/lib/services/api_config.dart @@ -0,0 +1,37 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +class ApiConfig { + // Base URL for the API - can be changed to point to different environments + // For local development, use: http://10.0.2.2:3000 (Android emulator) + // For iOS simulator, use: http://localhost:3000 + // For production, use your actual server URL + static String _baseUrl = 'http://10.0.2.2:3000'; + + static String get baseUrl => _baseUrl; + + static void setBaseUrl(String url) { + _baseUrl = url; + } + + /// Initialize the API configuration by loading the backend URL from storage + /// Returns true if a saved URL was loaded, false otherwise + static Future initialize() async { + try { + final prefs = await SharedPreferences.getInstance(); + final savedUrl = prefs.getString('backend_url'); + + if (savedUrl != null && savedUrl.isNotEmpty) { + _baseUrl = savedUrl; + return true; + } + return false; + } catch (e) { + // If initialization fails, keep the default URL + return false; + } + } + + // API timeout settings + static const Duration connectTimeout = Duration(seconds: 30); + static const Duration receiveTimeout = Duration(seconds: 30); +} diff --git a/mobile/lib/services/auth_service.dart b/mobile/lib/services/auth_service.dart new file mode 100644 index 00000000000..07d94de8c88 --- /dev/null +++ b/mobile/lib/services/auth_service.dart @@ -0,0 +1,334 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import '../models/auth_tokens.dart'; +import '../models/user.dart'; +import 'api_config.dart'; + +class AuthService { + final FlutterSecureStorage _storage = const FlutterSecureStorage(); + static const String _tokenKey = 'auth_tokens'; + static const String _userKey = 'user_data'; + + Future> login({ + required String email, + required String password, + required Map deviceInfo, + String? otpCode, + }) async { + try { + final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/auth/login'); + + final body = { + 'email': email, + 'password': password, + 'device': deviceInfo, + }; + + if (otpCode != null) { + body['otp_code'] = otpCode; + } + + final response = await http.post( + url, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: jsonEncode(body), + ).timeout(const Duration(seconds: 30)); + + debugPrint('Login response status: ${response.statusCode}'); + debugPrint('Login response body: ${response.body}'); + + final responseData = jsonDecode(response.body); + + if (response.statusCode == 200) { + // Store tokens + final tokens = AuthTokens.fromJson(responseData); + await _saveTokens(tokens); + + // Store user data - parse once and reuse + User? user; + if (responseData['user'] != null) { + user = User.fromJson(responseData['user']); + await _saveUser(user); + } + + return { + 'success': true, + 'tokens': tokens, + 'user': user, + }; + } else if (response.statusCode == 401 && responseData['mfa_required'] == true) { + return { + 'success': false, + 'mfa_required': true, + 'error': responseData['error'], + }; + } else { + return { + 'success': false, + 'error': responseData['error'] ?? responseData['errors']?.join(', ') ?? 'Login failed', + }; + } + } on SocketException catch (e, stackTrace) { + debugPrint('Login SocketException: $e\n$stackTrace'); + return { + 'success': false, + 'error': 'Network unavailable', + }; + } on TimeoutException catch (e, stackTrace) { + debugPrint('Login TimeoutException: $e\n$stackTrace'); + return { + 'success': false, + 'error': 'Request timed out', + }; + } on HttpException catch (e, stackTrace) { + debugPrint('Login HttpException: $e\n$stackTrace'); + return { + 'success': false, + 'error': 'Invalid response from server', + }; + } on FormatException catch (e, stackTrace) { + debugPrint('Login FormatException: $e\n$stackTrace'); + return { + 'success': false, + 'error': 'Invalid response from server', + }; + } on TypeError catch (e, stackTrace) { + debugPrint('Login TypeError: $e\n$stackTrace'); + return { + 'success': false, + 'error': 'Invalid response from server', + }; + } catch (e, stackTrace) { + debugPrint('Login unexpected error: $e\n$stackTrace'); + return { + 'success': false, + 'error': 'An unexpected error occurred', + }; + } + } + + Future> signup({ + required String email, + required String password, + required String firstName, + required String lastName, + required Map deviceInfo, + String? inviteCode, + }) async { + try { + final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/auth/signup'); + + final Map body = { + 'user': { + 'email': email, + 'password': password, + 'first_name': firstName, + 'last_name': lastName, + }, + 'device': deviceInfo, + }; + + if (inviteCode != null) { + body['invite_code'] = inviteCode; + } + + final response = await http.post( + url, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: jsonEncode(body), + ).timeout(const Duration(seconds: 30)); + + final responseData = jsonDecode(response.body); + + if (response.statusCode == 201) { + // Store tokens + final tokens = AuthTokens.fromJson(responseData); + await _saveTokens(tokens); + + // Store user data - parse once and reuse + User? user; + if (responseData['user'] != null) { + user = User.fromJson(responseData['user']); + await _saveUser(user); + } + + return { + 'success': true, + 'tokens': tokens, + 'user': user, + }; + } else { + return { + 'success': false, + 'error': responseData['error'] ?? responseData['errors']?.join(', ') ?? 'Signup failed', + }; + } + } on SocketException catch (e, stackTrace) { + debugPrint('Signup SocketException: $e\n$stackTrace'); + return { + 'success': false, + 'error': 'Network unavailable', + }; + } on TimeoutException catch (e, stackTrace) { + debugPrint('Signup TimeoutException: $e\n$stackTrace'); + return { + 'success': false, + 'error': 'Request timed out', + }; + } on HttpException catch (e, stackTrace) { + debugPrint('Signup HttpException: $e\n$stackTrace'); + return { + 'success': false, + 'error': 'Invalid response from server', + }; + } on FormatException catch (e, stackTrace) { + debugPrint('Signup FormatException: $e\n$stackTrace'); + return { + 'success': false, + 'error': 'Invalid response from server', + }; + } on TypeError catch (e, stackTrace) { + debugPrint('Signup TypeError: $e\n$stackTrace'); + return { + 'success': false, + 'error': 'Invalid response from server', + }; + } catch (e, stackTrace) { + debugPrint('Signup unexpected error: $e\n$stackTrace'); + return { + 'success': false, + 'error': 'An unexpected error occurred', + }; + } + } + + Future> refreshToken({ + required String refreshToken, + required Map deviceInfo, + }) async { + try { + final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/auth/refresh'); + + final response = await http.post( + url, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: jsonEncode({ + 'refresh_token': refreshToken, + 'device': deviceInfo, + }), + ).timeout(const Duration(seconds: 30)); + + final responseData = jsonDecode(response.body); + + if (response.statusCode == 200) { + final tokens = AuthTokens.fromJson(responseData); + await _saveTokens(tokens); + + return { + 'success': true, + 'tokens': tokens, + }; + } else { + return { + 'success': false, + 'error': responseData['error'] ?? 'Token refresh failed', + }; + } + } on SocketException catch (e, stackTrace) { + debugPrint('RefreshToken SocketException: $e\n$stackTrace'); + return { + 'success': false, + 'error': 'Network unavailable', + }; + } on TimeoutException catch (e, stackTrace) { + debugPrint('RefreshToken TimeoutException: $e\n$stackTrace'); + return { + 'success': false, + 'error': 'Request timed out', + }; + } on HttpException catch (e, stackTrace) { + debugPrint('RefreshToken HttpException: $e\n$stackTrace'); + return { + 'success': false, + 'error': 'Invalid response from server', + }; + } on FormatException catch (e, stackTrace) { + debugPrint('RefreshToken FormatException: $e\n$stackTrace'); + return { + 'success': false, + 'error': 'Invalid response from server', + }; + } on TypeError catch (e, stackTrace) { + debugPrint('RefreshToken TypeError: $e\n$stackTrace'); + return { + 'success': false, + 'error': 'Invalid response from server', + }; + } catch (e, stackTrace) { + debugPrint('RefreshToken unexpected error: $e\n$stackTrace'); + return { + 'success': false, + 'error': 'An unexpected error occurred', + }; + } + } + + Future logout() async { + await _storage.delete(key: _tokenKey); + await _storage.delete(key: _userKey); + } + + Future getStoredTokens() async { + final tokensJson = await _storage.read(key: _tokenKey); + if (tokensJson == null) return null; + + try { + return AuthTokens.fromJson(jsonDecode(tokensJson)); + } catch (e) { + return null; + } + } + + Future getStoredUser() async { + final userJson = await _storage.read(key: _userKey); + if (userJson == null) return null; + + try { + return User.fromJson(jsonDecode(userJson)); + } catch (e) { + return null; + } + } + + Future _saveTokens(AuthTokens tokens) async { + await _storage.write( + key: _tokenKey, + value: jsonEncode(tokens.toJson()), + ); + } + + Future _saveUser(User user) async { + await _storage.write( + key: _userKey, + value: jsonEncode({ + 'id': user.id, + 'email': user.email, + 'first_name': user.firstName, + 'last_name': user.lastName, + }), + ); + } +} diff --git a/mobile/lib/services/device_service.dart b/mobile/lib/services/device_service.dart new file mode 100644 index 00000000000..22b9ccbef45 --- /dev/null +++ b/mobile/lib/services/device_service.dart @@ -0,0 +1,76 @@ +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class DeviceService { + static const String _deviceIdKey = 'device_id'; + + Future> getDeviceInfo() async { + final prefs = await SharedPreferences.getInstance(); + + // Get or generate device ID + String? deviceId = prefs.getString(_deviceIdKey); + if (deviceId == null) { + deviceId = _generateDeviceId(); + await prefs.setString(_deviceIdKey, deviceId); + } + + return { + 'device_id': deviceId, + 'device_name': _getDeviceName(), + 'device_type': _getDeviceType(), + 'os_version': _getOsVersion(), + 'app_version': '1.0.0', + }; + } + + String _generateDeviceId() { + // Generate a unique device ID + final timestamp = DateTime.now().millisecondsSinceEpoch; + final random = timestamp.toString().hashCode.abs(); + return 'sure_mobile_${timestamp}_$random'; + } + + String _getDeviceName() { + if (kIsWeb) { + return 'Web Browser'; + } + try { + if (Platform.isAndroid) { + return 'Android Device'; + } else if (Platform.isIOS) { + return 'iOS Device'; + } + } catch (e) { + // Platform not available + } + return 'Mobile Device'; + } + + String _getDeviceType() { + if (kIsWeb) { + return 'web'; + } + try { + if (Platform.isAndroid) { + return 'android'; + } else if (Platform.isIOS) { + return 'ios'; + } + } catch (e) { + // Platform not available + } + return 'unknown'; + } + + String _getOsVersion() { + if (kIsWeb) { + return 'web'; + } + try { + return Platform.operatingSystemVersion; + } catch (e) { + return 'unknown'; + } + } +} diff --git a/mobile/lib/services/transactions_service.dart b/mobile/lib/services/transactions_service.dart new file mode 100644 index 00000000000..93c65c6c4a0 --- /dev/null +++ b/mobile/lib/services/transactions_service.dart @@ -0,0 +1,214 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import '../models/transaction.dart'; +import 'api_config.dart'; + +class TransactionsService { + Future> createTransaction({ + required String accessToken, + required String accountId, + required String name, + required String date, + required String amount, + required String currency, + required String nature, + String? notes, + }) async { + final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/transactions'); + + final body = { + 'transaction': { + 'account_id': accountId, + 'name': name, + 'date': date, + 'amount': amount, + 'currency': currency, + 'nature': nature, + if (notes != null) 'notes': notes, + } + }; + + try { + final response = await http.post( + url, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'Authorization': 'Bearer $accessToken', + }, + body: jsonEncode(body), + ).timeout(const Duration(seconds: 30)); + + if (response.statusCode == 200 || response.statusCode == 201) { + final responseData = jsonDecode(response.body); + return { + 'success': true, + 'transaction': Transaction.fromJson(responseData), + }; + } else if (response.statusCode == 401) { + return { + 'success': false, + 'error': 'unauthorized', + }; + } else { + try { + final responseData = jsonDecode(response.body); + return { + 'success': false, + 'error': responseData['error'] ?? 'Failed to create transaction', + }; + } catch (e) { + return { + 'success': false, + 'error': 'Failed to create transaction: ${response.body}', + }; + } + } + } catch (e) { + return { + 'success': false, + 'error': 'Network error: ${e.toString()}', + }; + } + } + + Future> getTransactions({ + required String accessToken, + String? accountId, + }) async { + final baseUri = Uri.parse('${ApiConfig.baseUrl}/api/v1/transactions'); + final url = accountId != null + ? baseUri.replace(queryParameters: {'account_id': accountId}) + : baseUri; + + try { + final response = await http.get( + url, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'Authorization': 'Bearer $accessToken', + }, + ).timeout(const Duration(seconds: 30)); + + if (response.statusCode == 200) { + final responseData = jsonDecode(response.body); + + // Handle both array and object responses + List transactionsJson; + if (responseData is List) { + transactionsJson = responseData; + } else if (responseData is Map && responseData.containsKey('transactions')) { + transactionsJson = responseData['transactions']; + } else { + transactionsJson = []; + } + + final transactions = transactionsJson + .map((json) => Transaction.fromJson(json)) + .toList(); + + return { + 'success': true, + 'transactions': transactions, + }; + } else if (response.statusCode == 401) { + return { + 'success': false, + 'error': 'unauthorized', + }; + } else { + return { + 'success': false, + 'error': 'Failed to fetch transactions', + }; + } + } catch (e) { + return { + 'success': false, + 'error': 'Network error: ${e.toString()}', + }; + } + } + + Future> deleteTransaction({ + required String accessToken, + required String transactionId, + }) async { + final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/transactions/$transactionId'); + + try { + final response = await http.delete( + url, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'Authorization': 'Bearer $accessToken', + }, + ).timeout(const Duration(seconds: 30)); + + if (response.statusCode == 200 || response.statusCode == 204) { + return { + 'success': true, + }; + } else if (response.statusCode == 401) { + return { + 'success': false, + 'error': 'unauthorized', + }; + } else { + try { + final responseData = jsonDecode(response.body); + return { + 'success': false, + 'error': responseData['error'] ?? 'Failed to delete transaction', + }; + } catch (e) { + return { + 'success': false, + 'error': 'Failed to delete transaction: ${response.body}', + }; + } + } + } catch (e) { + return { + 'success': false, + 'error': 'Network error: ${e.toString()}', + }; + } + } + + Future> deleteMultipleTransactions({ + required String accessToken, + required List transactionIds, + }) async { + try { + final results = await Future.wait( + transactionIds.map((id) => deleteTransaction( + accessToken: accessToken, + transactionId: id, + )), + ); + + final allSuccess = results.every((result) => result['success'] == true); + + if (allSuccess) { + return { + 'success': true, + 'deleted_count': transactionIds.length, + }; + } else { + final failedCount = results.where((r) => r['success'] != true).length; + return { + 'success': false, + 'error': 'Failed to delete $failedCount transactions', + }; + } + } catch (e) { + return { + 'success': false, + 'error': 'Network error: ${e.toString()}', + }; + } + } +} diff --git a/mobile/lib/widgets/account_card.dart b/mobile/lib/widgets/account_card.dart new file mode 100644 index 00000000000..f3cbd863661 --- /dev/null +++ b/mobile/lib/widgets/account_card.dart @@ -0,0 +1,168 @@ +import 'package:flutter/material.dart'; +import '../models/account.dart'; + +class AccountCard extends StatelessWidget { + final Account account; + final VoidCallback? onTap; + final VoidCallback? onSwipe; + + const AccountCard({ + super.key, + required this.account, + this.onTap, + this.onSwipe, + }); + + IconData _getAccountIcon() { + switch (account.accountType) { + case 'depository': + return Icons.account_balance; + case 'credit_card': + return Icons.credit_card; + case 'investment': + return Icons.trending_up; + case 'loan': + return Icons.receipt_long; + case 'property': + return Icons.home; + case 'vehicle': + return Icons.directions_car; + case 'crypto': + return Icons.currency_bitcoin; + case 'other_asset': + return Icons.category; + case 'other_liability': + return Icons.payment; + default: + return Icons.account_balance_wallet; + } + } + + Color _getAccountColor(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + if (account.isAsset) { + return Colors.green; + } else if (account.isLiability) { + return Colors.red; + } + return colorScheme.primary; + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final accountColor = _getAccountColor(context); + + final cardContent = Card( + margin: const EdgeInsets.only(bottom: 12), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + // Account icon + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: accountColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + _getAccountIcon(), + color: accountColor, + size: 24, + ), + ), + const SizedBox(width: 16), + + // Account info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + account.name, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + account.displayAccountType, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + + // Balance + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + account.balance, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: account.isLiability ? Colors.red : null, + ), + ), + const SizedBox(height: 4), + Text( + account.currency, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ], + ), + ), + ), + ); + + // If onSwipe is provided, wrap with Dismissible + if (onSwipe != null) { + return Dismissible( + key: Key('account_${account.id}'), + direction: DismissDirection.endToStart, + confirmDismiss: (direction) async { + // Don't actually dismiss, just trigger the swipe action + onSwipe?.call(); + return false; // Don't remove the item + }, + background: Container( + margin: const EdgeInsets.only(bottom: 12), + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 20), + decoration: BoxDecoration( + color: Colors.blue, + borderRadius: BorderRadius.circular(12), + ), + child: const Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.receipt_long, color: Colors.white, size: 28), + SizedBox(height: 4), + Text( + 'Transactions', + style: TextStyle(color: Colors.white, fontSize: 12), + ), + ], + ), + ), + child: cardContent, + ); + } + + return cardContent; + } +} diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock new file mode 100644 index 00000000000..27fe57ab620 --- /dev/null +++ b/mobile/pubspec.lock @@ -0,0 +1,578 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + archive: + dependency: transitive + description: + name: archive + sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" + url: "https://pub.dev" + source: hosted + version: "4.0.7" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + 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_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" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + 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: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_launcher_icons: + dependency: "direct dev" + description: + name: flutter_launcher_icons + sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea" + url: "https://pub.dev" + source: hosted + version: "0.13.1" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "9e8c3858111da373efc5aa341de011d9bd23e2c5c5e0c62bccf32438e192d7b1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" + url: "https://pub.dev" + source: hosted + version: "9.2.4" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" + url: "https://pub.dev" + source: hosted + version: "3.1.3" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: "51555e36056541237b15b57afc31a0f53d4f9aefd9bd00873a6dc0090e54e332" + url: "https://pub.dev" + source: hosted + version: "4.6.0" + intl: + dependency: "direct main" + description: + name: intl + sha256: "3bc132a9dbce73a7e4a21a17d06e1878839ffbf975568bc875c60537824b0c4d" + url: "https://pub.dev" + source: hosted + version: "0.18.1" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" + source: hosted + version: "4.9.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" + url: "https://pub.dev" + source: hosted + version: "10.0.9" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 + url: "https://pub.dev" + source: hosted + version: "3.0.9" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + lints: + dependency: transitive + description: + name: lints + sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290 + url: "https://pub.dev" + source: hosted + version: "3.0.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: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + url: "https://pub.dev" + source: hosted + version: "1.16.0" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "3b4c1fc3aa55ddc9cd4aa6759984330d5c8e66aa7702a6223c61540dc6380c37" + url: "https://pub.dev" + source: hosted + version: "2.2.19" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + 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: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1" + url: "https://pub.dev" + source: hosted + version: "7.0.1" + 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" + posix: + dependency: transitive + description: + name: posix + sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" + url: "https://pub.dev" + source: hosted + version: "6.0.3" + provider: + dependency: "direct main" + description: + name: provider + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" + url: "https://pub.dev" + source: hosted + version: "6.1.5+1" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + url: "https://pub.dev" + source: hosted + version: "2.5.3" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: bd14436108211b0d4ee5038689a56d4ae3620fd72fd6036e113bf1345bc74d9e + url: "https://pub.dev" + source: hosted + version: "2.4.13" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + 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: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" + source: hosted + version: "1.10.1" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd + url: "https://pub.dev" + source: hosted + version: "0.7.4" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 + url: "https://pub.dev" + source: hosted + version: "15.0.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + 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.8.0 <4.0.0" + flutter: ">=3.29.0" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml new file mode 100644 index 00000000000..89ad9ac682f --- /dev/null +++ b/mobile/pubspec.yaml @@ -0,0 +1,36 @@ +name: sure_mobile +description: A mobile app for Sure personal finance management +publish_to: 'none' +version: 1.0.0+1 + +environment: + sdk: '>=3.0.0 <4.0.0' + +dependencies: + flutter: + sdk: flutter + cupertino_icons: ^1.0.6 + http: ^1.1.0 + provider: ^6.1.1 + shared_preferences: ^2.2.2 + flutter_secure_storage: ^9.0.0 + intl: ^0.18.1 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^3.0.1 + flutter_launcher_icons: ^0.13.1 + +flutter: + uses-material-design: true + assets: + - assets/icon/ + +flutter_launcher_icons: + android: true + ios: true + image_path: "assets/icon/app_icon.png" + adaptive_icon_background: "#000000" + adaptive_icon_foreground: "assets/icon/app_icon.png" + remove_alpha_ios: true diff --git a/mobile/test/widget_test.dart b/mobile/test/widget_test.dart new file mode 100644 index 00000000000..e207300da4c --- /dev/null +++ b/mobile/test/widget_test.dart @@ -0,0 +1,9 @@ +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Smoke test - app can be instantiated', (WidgetTester tester) async { + // This is a placeholder smoke test to satisfy flutter test requirements. + // Add more comprehensive tests as the app grows. + expect(true, isTrue); + }); +}