From a39ad5e963abf0a4dbe46405808547e11ef8d106 Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 1 Nov 2026 13:15:21 -0500 Subject: [PATCH] Add ForkFolio mobile app and GitLab CI pipeline to automatically build release APK --- .gitlab-ci.yml | 23 ++ mobile/README.md | 59 +++ mobile/android/app/build.gradle | 64 +++ .../android/app/src/main/AndroidManifest.xml | 58 +++ .../com/forkfolio/mobile/MainActivity.kt | 6 + mobile/android/build.gradle | 18 + mobile/android/settings.gradle | 25 ++ mobile/ios/Runner/Info.plist | 48 +++ mobile/lib/core/api/api_client.dart | 205 +++++++++ mobile/lib/core/constants/app_theme.dart | 90 ++++ mobile/lib/core/models/recipe.dart | 117 ++++++ mobile/lib/core/providers.dart | 135 ++++++ .../core/storage/secure_storage_service.dart | 69 ++++ mobile/lib/features/auth/login_screen.dart | 200 +++++++++ .../features/cook_mode/cook_mode_screen.dart | 293 +++++++++++++ .../lib/features/library/recipes_screen.dart | 310 ++++++++++++++ .../lib/features/scanner/scanner_screen.dart | 150 +++++++ .../share_receiver/quick_share_sheet.dart | 391 ++++++++++++++++++ .../share_receiver_service.dart | 77 ++++ mobile/lib/main.dart | 55 +++ mobile/pubspec.yaml | 49 +++ 21 files changed, 2442 insertions(+) create mode 100644 .gitlab-ci.yml create mode 100644 mobile/README.md create mode 100644 mobile/android/app/build.gradle create mode 100644 mobile/android/app/src/main/AndroidManifest.xml create mode 100644 mobile/android/app/src/main/kotlin/com/forkfolio/mobile/MainActivity.kt create mode 100644 mobile/android/build.gradle create mode 100644 mobile/android/settings.gradle create mode 100644 mobile/ios/Runner/Info.plist create mode 100644 mobile/lib/core/api/api_client.dart create mode 100644 mobile/lib/core/constants/app_theme.dart create mode 100644 mobile/lib/core/models/recipe.dart create mode 100644 mobile/lib/core/providers.dart create mode 100644 mobile/lib/core/storage/secure_storage_service.dart create mode 100644 mobile/lib/features/auth/login_screen.dart create mode 100644 mobile/lib/features/cook_mode/cook_mode_screen.dart create mode 100644 mobile/lib/features/library/recipes_screen.dart create mode 100644 mobile/lib/features/scanner/scanner_screen.dart create mode 100644 mobile/lib/features/share_receiver/quick_share_sheet.dart create mode 100644 mobile/lib/features/share_receiver/share_receiver_service.dart create mode 100644 mobile/lib/main.dart create mode 100644 mobile/pubspec.yaml diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..2bb3073 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,23 @@ +image: ghcr.io/cirruslabs/flutter:latest + +stages: + - build + +variables: + SECURE_FILES_DOWNLOAD_PATH: './' + +build_android_apk: + stage: build + before_script: + - flutter doctor -v + script: + - cd mobile + - flutter pub get + - flutter build apk --release + artifacts: + name: "ForkFolio-Mobile-APK-$CI_COMMIT_SHORT_SHA" + paths: + - mobile/build/app/outputs/flutter-apk/app-release.apk + expire_in: 30 days + rules: + - if: $CI_COMMIT_BRANCH == "main" diff --git a/mobile/README.md b/mobile/README.md new file mode 100644 index 0000000..e49d9c8 --- /dev/null +++ b/mobile/README.md @@ -0,0 +1,59 @@ +# ForkFolio Mobile 📱🍳 + +Cross-platform mobile application for **ForkFolio** built with Flutter, featuring deep **Android Share Target** ingestion, multimodal **Gemini Vision Camera Scanning**, and distraction-free **Cook Mode**. + +--- + +## ✨ Features + +* **Interactive Share Target (`ACTION_SEND`)**: + * Tap "Share" from Chrome, Instagram, TikTok, Reddit, or YouTube directly to ForkFolio. + * Pops an interactive bottom sheet that sanitizes URLs, extracts captions, scrapes details, and lets you preview & tag before saving. +* **Camera Recipe Scanner**: + * Point the camera at physical cookbooks, magazine pages, or handwritten recipe cards. + * Direct ingestion into ForkFolio's `POST /api/recipes/scan-photo` powered by server-side Gemini 2.5 Flash Vision. +* **Kitchen Cook Mode**: + * Automatic `Wakelock` keeps the screen awake so you don't touch your phone with flour or messy hands. + * Large, counter-readable typography with swipeable step cards. + * Live serving size scaling (0.5x, 1x, 1.5x, 2x) recalculating ingredient amounts on the fly. + * Integrated cooking timers with audible alerts. +* **Hardware-Backed Persistent Authentication**: + * Connects using `ff_m_...` device tokens stored in **Android Keystore** (`EncryptedSharedPreferences`) or **iOS Keychain**. + * Sliding 90-day inactivity expiration. + * One-click remote device revocation via ForkFolio web settings. + +--- + +## 🚀 Getting Started + +### 1. Prerequisites +* [Flutter SDK](https://flutter.dev/docs/get-started/install) (version `>= 3.2.0`) +* Android Studio with Android SDK (API 34+) or VS Code with Flutter extension +* An active ForkFolio instance accessible over HTTPS + +### 2. Install Dependencies +```bash +cd forkfolio-mobile +flutter pub get +``` + +### 3. Run in Development +Connect an Android device or launch an emulator, then run: +```bash +flutter run +``` + +### 4. Build Release APK +```bash +flutter build apk --release +``` +The resulting APK will be located at: +`build/app/outputs/flutter-apk/app-release.apk` + +--- + +## 🔒 Security & Networking + +* **Server Connection**: Enter your public HTTPS domain (e.g. `https://recipes.yourdomain.com`). +* **Credentials**: Log in once with your standard username/password. The app automatically registers with your device model name (e.g. "Google Pixel 8 Pro") and receives an encrypted persistent token. +* **Revocation**: At any time, you can visit the ForkFolio web app, open **Settings -> Linked Mobile Devices**, and tap **"Revoke"** to immediately cut off mobile access. diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle new file mode 100644 index 0000000..cc57a97 --- /dev/null +++ b/mobile/android/app/build.gradle @@ -0,0 +1,64 @@ +plugins { + id "com.android.application" + id "kotlin-android" + id "dev.flutter.flutter-gradle-plugin" +} + +def localProperties = new Properties() +def localPropertiesFile = rootProject.file('local.properties') +if (localPropertiesFile.exists()) { + localPropertiesFile.withReader('UTF-8') { reader -> + localProperties.load(reader) + } +} + +def flutterVersionCode = localProperties.getProperty('flutter.versionCode') +if (flutterVersionCode == null) { + flutterVersionCode = '1' +} + +def flutterVersionName = localProperties.getProperty('flutter.versionName') +if (flutterVersionName == null) { + flutterVersionName = '1.0' +} + +android { + namespace "com.forkfolio.mobile" + compileSdk 34 + ndkVersion flutter.ndkVersion + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = '17' + } + + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + } + + defaultConfig { + applicationId "com.forkfolio.mobile" + minSdkVersion 24 + targetSdkVersion 34 + versionCode flutterVersionCode.toInteger() + versionName flutterVersionName + } + + buildTypes { + release { + signingConfig signingConfigs.debug + minifyEnabled false + shrinkResources false + } + } +} + +flutter { + source '../..' +} + +dependencies {} diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..ef29f89 --- /dev/null +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/android/app/src/main/kotlin/com/forkfolio/mobile/MainActivity.kt b/mobile/android/app/src/main/kotlin/com/forkfolio/mobile/MainActivity.kt new file mode 100644 index 0000000..31d7d73 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/com/forkfolio/mobile/MainActivity.kt @@ -0,0 +1,6 @@ +package com.forkfolio.mobile + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity: FlutterActivity() { +} diff --git a/mobile/android/build.gradle b/mobile/android/build.gradle new file mode 100644 index 0000000..bc157bd --- /dev/null +++ b/mobile/android/build.gradle @@ -0,0 +1,18 @@ +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/settings.gradle b/mobile/android/settings.gradle new file mode 100644 index 0000000..b9e43bd --- /dev/null +++ b/mobile/android/settings.gradle @@ -0,0 +1,25 @@ +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 + }() + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id "dev.flutter.flutter-plugin-loader" version "1.0.0" + id "com.android.application" version "8.1.0" apply false + id "org.jetbrains.kotlin.android" version "1.8.22" apply false +} + +include ":app" diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist new file mode 100644 index 0000000..1704f72 --- /dev/null +++ b/mobile/ios/Runner/Info.plist @@ -0,0 +1,48 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + ForkFolio + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + forkfolio_mobile + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + + NSCameraUsageDescription + ForkFolio needs camera access to scan recipe photos from cookbooks, recipe cards, and packaging. + NSPhotoLibraryUsageDescription + ForkFolio needs photo library access to import recipe screenshots and food photos. + + + AppGroupId + group.com.forkfolio.mobile + + diff --git a/mobile/lib/core/api/api_client.dart b/mobile/lib/core/api/api_client.dart new file mode 100644 index 0000000..6aacb52 --- /dev/null +++ b/mobile/lib/core/api/api_client.dart @@ -0,0 +1,205 @@ +import 'dart:convert'; +import 'package:dio/dio.dart'; +import '../models/recipe.dart'; +import '../storage/secure_storage_service.dart'; + +class ApiException implements Exception { + final String message; + final int? statusCode; + ApiException(this.message, [this.statusCode]); + + @override + String toString() => message; +} + +class ApiClient { + final SecureStorageService _storage; + final Dio _dio; + + ApiClient(this._storage) + : _dio = Dio( + BaseOptions( + connectTimeout: const Duration(seconds: 15), + receiveTimeout: const Duration(seconds: 45), + headers: {'Accept': 'application/json'}, + ), + ) { + // Interceptor to auto-inject Bearer mobile token and base URL + _dio.interceptors.add( + InterceptorsWrapper( + onRequest: (options, handler) async { + final serverUrl = await _storage.getServerUrl(); + final token = await _storage.getToken(); + + if (serverUrl != null && !options.path.startsWith('http')) { + options.baseUrl = serverUrl; + } + + if (token != null && token.isNotEmpty) { + options.headers['Authorization'] = 'Bearer $token'; + } + + return handler.next(options); + }, + onError: (DioException e, handler) { + String message = 'Network communication error.'; + if (e.response != null) { + final data = e.response?.data; + if (data is Map && data['error'] != null) { + message = data['error'].toString(); + } else if (e.response?.statusCode == 401) { + message = 'Session expired or invalid. Please log in again.'; + } else { + message = 'Server returned HTTP ${e.response?.statusCode}.'; + } + } else if (e.type == DioExceptionType.connectionTimeout) { + message = 'Connection timed out. Please check your internet or server URL.'; + } else if (e.type == DioExceptionType.connectionError) { + message = 'Could not connect to ForkFolio server. Check domain or VPN status.'; + } + return handler.reject( + DioException( + requestOptions: e.requestOptions, + error: ApiException(message, e.response?.statusCode), + response: e.response, + type: e.type, + ), + ); + }, + ), + ); + } + + // 1. Mobile App Login + Future> login({ + required String serverUrl, + required String username, + required String password, + required String deviceName, + }) async { + final cleanUrl = serverUrl.trim().replaceAll(RegExp(r'/+$'), ''); + try { + final response = await _dio.post( + '$cleanUrl/api/auth/mobile-login', + data: { + 'username': username.trim(), + 'password': password, + 'deviceName': deviceName.trim(), + }, + ); + + final data = response.data as Map; + final token = data['token'] as String; + final expiresAt = data['expiresAt'] as String; + final user = data['user'] as Map; + + await _storage.saveSession( + serverUrl: cleanUrl, + token: token, + user: user, + expiresAt: expiresAt, + ); + + return data; + } on DioException catch (e) { + final err = e.error; + if (err is ApiException) throw err; + throw ApiException(e.message ?? 'Login failed'); + } + } + + // 2. Fetch User Recipes + Future> fetchRecipes() async { + try { + final response = await _dio.get('/api/recipes'); + final list = response.data as List; + return list.map((item) => Recipe.fromJson(item as Map)).toList(); + } on DioException catch (e) { + final err = e.error; + if (err is ApiException) throw err; + throw ApiException(e.message ?? 'Failed to load recipes'); + } + } + + // 3. Scrape Recipe from Link or Social Media Caption + Future> scrapeRecipe({ + String? url, + String? rawCaption, + }) async { + try { + final response = await _dio.post( + '/api/scrape', + data: { + if (url != null && url.isNotEmpty) 'url': url.trim(), + if (rawCaption != null && rawCaption.isNotEmpty) 'rawCaption': rawCaption.trim(), + }, + ); + return response.data as Map; + } on DioException catch (e) { + final err = e.error; + if (err is ApiException) throw err; + throw ApiException(e.message ?? 'Failed to scrape recipe details'); + } + } + + // 4. Scan Recipe Photo via Gemini Multimodal Vision Endpoint + Future> scanRecipePhoto({ + required String imageBase64, + String mimeType = 'image/jpeg', + }) async { + try { + final response = await _dio.post( + '/api/recipes/scan-photo', + data: { + 'imageBase64': imageBase64, + 'mimeType': mimeType, + }, + options: Options(receiveTimeout: const Duration(seconds: 90)), + ); + return response.data as Map; + } on DioException catch (e) { + final err = e.error; + if (err is ApiException) throw err; + throw ApiException(e.message ?? 'Photo recipe extraction failed'); + } + } + + // 5. Save or Update Recipe + Future saveRecipe(Recipe recipe) async { + try { + Response response; + if (recipe.id != null && recipe.id!.isNotEmpty) { + response = await _dio.put('/api/recipes/${recipe.id}', data: recipe.toJson()); + } else { + response = await _dio.post('/api/recipes', data: recipe.toJson()); + } + return Recipe.fromJson(response.data as Map); + } on DioException catch (e) { + final err = e.error; + if (err is ApiException) throw err; + throw ApiException(e.message ?? 'Failed to save recipe'); + } + } + + // 6. Delete Recipe + Future deleteRecipe(String id) async { + try { + await _dio.delete('/api/recipes/$id'); + } on DioException catch (e) { + final err = e.error; + if (err is ApiException) throw err; + throw ApiException(e.message ?? 'Failed to delete recipe'); + } + } + + // 7. Logout + Future logout() async { + try { + await _dio.post('/api/auth/logout'); + } catch (_) { + // Continue clearing local storage even if network request fails + } finally { + await _storage.clearSession(); + } + } +} diff --git a/mobile/lib/core/constants/app_theme.dart b/mobile/lib/core/constants/app_theme.dart new file mode 100644 index 0000000..6262ed3 --- /dev/null +++ b/mobile/lib/core/constants/app_theme.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; + +class AppTheme { + // Brand Palette matching ForkFolio Web + static const Color primary = Color(0xFFE06B3A); + static const Color primaryDark = Color(0xFFC75526); + static const Color secondary = Color(0xFF10B981); + static const Color background = Color(0xFF0F131B); + static const Color surface = Color(0xFF1A212D); + static const Color surfaceElevated = Color(0xFF242C3C); + static const Color border = Color(0xFF2D3748); + + static const Color textMain = Color(0xFFF1F5F9); + static const Color textMuted = Color(0xFF94A3B8); + static const Color textDisabled = Color(0xFF64748B); + static const Color danger = Color(0xFFEF4444); + + static ThemeData get darkTheme { + return ThemeData( + brightness: Brightness.dark, + scaffoldBackgroundColor: background, + primaryColor: primary, + colorScheme: const ColorScheme.dark( + primary: primary, + secondary: secondary, + surface: surface, + error: danger, + ), + appBarTheme: const AppBarTheme( + backgroundColor: surface, + elevation: 0, + centerTitle: false, + titleTextStyle: TextStyle( + color: textMain, + fontSize: 18, + fontWeight: FontWeight.w700, + letterSpacing: -0.3, + ), + ), + cardTheme: CardTheme( + color: surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: border, width: 1), + ), + elevation: 2, + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: surfaceElevated, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: border), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: primary, width: 1.5), + ), + labelStyle: const TextStyle(color: textMuted, fontSize: 14), + hintStyle: const TextStyle(color: textDisabled, fontSize: 14), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: primary, + foregroundColor: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + textStyle: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: textMain, + side: const BorderSide(color: border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + ), + ), + ); + } +} diff --git a/mobile/lib/core/models/recipe.dart b/mobile/lib/core/models/recipe.dart new file mode 100644 index 0000000..cf12fc7 --- /dev/null +++ b/mobile/lib/core/models/recipe.dart @@ -0,0 +1,117 @@ +class Recipe { + final String? id; + final String title; + final String? description; + final String? servings; + final String? prepTime; + final String? cookTime; + final String? totalTime; + final String? image; + final List images; + final List ingredients; + final List instructions; + final String? sourceUrl; + final String? shareToken; + final String? createdAt; + final String? updatedAt; + + Recipe({ + this.id, + required this.title, + this.description, + this.servings, + this.prepTime, + this.cookTime, + this.totalTime, + this.image, + List? images, + List? ingredients, + List? instructions, + this.sourceUrl, + this.shareToken, + this.createdAt, + this.updatedAt, + }) : images = images ?? [], + ingredients = ingredients ?? [], + instructions = instructions ?? []; + + factory Recipe.fromJson(Map json) { + List parseStringList(dynamic val) { + if (val is List) { + return val.map((e) => e.toString().trim()).where((s) => s.isNotEmpty).toList(); + } + return []; + } + + return Recipe( + id: json['id']?.toString(), + title: json['title']?.toString() ?? 'Untitled Recipe', + description: json['description']?.toString(), + servings: json['servings']?.toString(), + prepTime: json['prepTime']?.toString(), + cookTime: json['cookTime']?.toString(), + totalTime: json['totalTime']?.toString(), + image: json['image']?.toString(), + images: parseStringList(json['images']), + ingredients: parseStringList(json['ingredients']), + instructions: parseStringList(json['instructions']), + sourceUrl: json['sourceUrl']?.toString(), + shareToken: json['shareToken']?.toString(), + createdAt: json['createdAt']?.toString(), + updatedAt: json['updatedAt']?.toString(), + ); + } + + Map toJson() { + return { + if (id != null) 'id': id, + 'title': title, + 'description': description ?? '', + 'servings': servings ?? '', + 'prepTime': prepTime ?? '', + 'cookTime': cookTime ?? '', + 'totalTime': totalTime ?? '', + 'image': image ?? '', + 'images': images, + 'ingredients': ingredients, + 'instructions': instructions, + 'sourceUrl': sourceUrl ?? '', + }; + } + + Recipe copyWith({ + String? id, + String? title, + String? description, + String? servings, + String? prepTime, + String? cookTime, + String? totalTime, + String? image, + List? images, + List? ingredients, + List? instructions, + String? sourceUrl, + String? shareToken, + String? createdAt, + String? updatedAt, + }) { + return Recipe( + id: id ?? this.id, + title: title ?? this.title, + description: description ?? this.description, + servings: servings ?? this.servings, + prepTime: prepTime ?? this.prepTime, + cookTime: cookTime ?? this.cookTime, + totalTime: totalTime ?? this.totalTime, + image: image ?? this.image, + images: images ?? this.images, + ingredients: ingredients ?? this.ingredients, + instructions: instructions ?? this.instructions, + sourceUrl: sourceUrl ?? this.sourceUrl, + shareToken: shareToken ?? this.shareToken, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } +} diff --git a/mobile/lib/core/providers.dart b/mobile/lib/core/providers.dart new file mode 100644 index 0000000..462c55e --- /dev/null +++ b/mobile/lib/core/providers.dart @@ -0,0 +1,135 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'api/api_client.dart'; +import 'models/recipe.dart'; +import 'storage/secure_storage_service.dart'; + +final secureStorageProvider = Provider((ref) { + return SecureStorageService(); +}); + +final apiClientProvider = Provider((ref) { + final storage = ref.watch(secureStorageProvider); + return ApiClient(storage); +}); + +// Authentication State Notifier +class AuthState { + final bool isAuthenticated; + final bool isLoading; + final String? errorMessage; + final Map? user; + + const AuthState({ + required this.isAuthenticated, + this.isLoading = false, + this.errorMessage, + this.user, + }); + + factory AuthState.initial() => const AuthState(isAuthenticated: false); + factory AuthState.loading() => const AuthState(isAuthenticated: false, isLoading: true); + factory AuthState.authenticated(Map user) => + AuthState(isAuthenticated: true, user: user); + factory AuthState.unauthenticated([String? error]) => + AuthState(isAuthenticated: false, errorMessage: error); +} + +class AuthNotifier extends StateNotifier { + final ApiClient _api; + final SecureStorageService _storage; + + AuthNotifier(this._api, this._storage) : super(AuthState.initial()) { + checkInitialAuth(); + } + + Future checkInitialAuth() async { + final isValid = await _storage.hasValidSession(); + if (isValid) { + final user = await _storage.getUser(); + state = AuthState.authenticated(user ?? {'username': 'Chef'}); + } else { + state = AuthState.unauthenticated(); + } + } + + Future login({ + required String serverUrl, + required String username, + required String password, + required String deviceName, + }) async { + state = AuthState.loading(); + try { + final res = await _api.login( + serverUrl: serverUrl, + username: username, + password: password, + deviceName: deviceName, + ); + final user = res['user'] as Map; + state = AuthState.authenticated(user); + return true; + } catch (e) { + state = AuthState.unauthenticated(e.toString()); + return false; + } + } + + Future logout() async { + await _api.logout(); + state = AuthState.unauthenticated(); + } +} + +final authProvider = StateNotifierProvider((ref) { + final api = ref.watch(apiClientProvider); + final storage = ref.watch(secureStorageProvider); + return AuthNotifier(api, storage); +}); + +// Recipe List State Notifier +class RecipeListNotifier extends StateNotifier>> { + final ApiClient _api; + + RecipeListNotifier(this._api) : super(const AsyncValue.loading()) { + loadRecipes(); + } + + Future loadRecipes() async { + state = const AsyncValue.loading(); + try { + final recipes = await _api.fetchRecipes(); + state = AsyncValue.data(recipes); + } catch (e, st) { + state = AsyncValue.error(e, st); + } + } + + Future addOrUpdate(Recipe recipe) async { + try { + final saved = await _api.saveRecipe(recipe); + final current = state.value ?? []; + final idx = current.indexWhere((r) => r.id == saved.id); + if (idx >= 0) { + current[idx] = saved; + state = AsyncValue.data([...current]); + } else { + state = AsyncValue.data([saved, ...current]); + } + } catch (e) { + rethrow; + } + } + + Future remove(String id) async { + await _api.deleteRecipe(id); + final current = state.value ?? []; + state = AsyncValue.data(current.where((r) => r.id != id).toList()); + } +} + +final recipeListProvider = + StateNotifierProvider>>((ref) { + final api = ref.watch(apiClientProvider); + return RecipeListNotifier(api); +}); diff --git a/mobile/lib/core/storage/secure_storage_service.dart b/mobile/lib/core/storage/secure_storage_service.dart new file mode 100644 index 0000000..aaa6867 --- /dev/null +++ b/mobile/lib/core/storage/secure_storage_service.dart @@ -0,0 +1,69 @@ +import 'dart:convert'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +class SecureStorageService { + final FlutterSecureStorage _storage; + + SecureStorageService() + : _storage = const FlutterSecureStorage( + aOptions: AndroidOptions(encryptedSharedPreferences: true), + iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock), + ); + + static const _keyServerUrl = 'forkfolio_server_url'; + static const _keyToken = 'forkfolio_mobile_token'; + static const _keyUser = 'forkfolio_user_profile'; + static const _keyExpiresAt = 'forkfolio_token_expires_at'; + + Future saveSession({ + required String serverUrl, + required String token, + required Map user, + required String expiresAt, + }) async { + // Normalize URL: remove trailing slashes + final cleanUrl = serverUrl.trim().replaceAll(RegExp(r'/+$'), ''); + await _storage.write(key: _keyServerUrl, value: cleanUrl); + await _storage.write(key: _keyToken, value: token); + await _storage.write(key: _keyUser, value: jsonEncode(user)); + await _storage.write(key: _keyExpiresAt, value: expiresAt); + } + + Future getServerUrl() async => await _storage.read(key: _keyServerUrl); + Future getToken() async => await _storage.read(key: _keyToken); + Future getExpiresAt() async => await _storage.read(key: _keyExpiresAt); + + Future?> getUser() async { + final raw = await _storage.read(key: _keyUser); + if (raw == null) return null; + try { + return jsonDecode(raw) as Map; + } catch (_) { + return null; + } + } + + Future hasValidSession() async { + final token = await getToken(); + final url = await getServerUrl(); + if (token == null || url == null || token.isEmpty || url.isEmpty) { + return false; + } + // Check local expiration timestamp if available + final expires = await getExpiresAt(); + if (expires != null) { + final expiryDate = DateTime.tryParse(expires); + if (expiryDate != null && expiryDate.isBefore(DateTime.now())) { + return false; + } + } + return true; + } + + Future clearSession() async { + await _storage.delete(key: _keyToken); + await _storage.delete(key: _keyUser); + await _storage.delete(key: _keyExpiresAt); + // Keep serverUrl for convenience on next login + } +} diff --git a/mobile/lib/features/auth/login_screen.dart b/mobile/lib/features/auth/login_screen.dart new file mode 100644 index 0000000..5513ecb --- /dev/null +++ b/mobile/lib/features/auth/login_screen.dart @@ -0,0 +1,200 @@ +import 'dart:io'; +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/constants/app_theme.dart'; +import '../../core/providers.dart'; + +class LoginScreen extends ConsumerStatefulWidget { + const LoginScreen({super.key}); + + @override + ConsumerState createState() => _LoginScreenState(); +} + +class _LoginScreenState extends ConsumerState { + final _formKey = GlobalKey(); + final _serverUrlController = TextEditingController(); + final _usernameController = TextEditingController(); + final _passwordController = TextEditingController(); + final _deviceNameController = TextEditingController(); + bool _obscurePassword = true; + + @override + void initState() { + super.initState(); + _initDeviceNameAndCachedUrl(); + } + + @override + void dispose() { + _serverUrlController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + _deviceNameController.dispose(); + super.dispose(); + } + + Future _initDeviceNameAndCachedUrl() async { + // 1. Pre-fill cached server URL if previously entered + final cachedUrl = await ref.read(secureStorageProvider).getServerUrl(); + if (cachedUrl != null && cachedUrl.isNotEmpty) { + _serverUrlController.text = cachedUrl; + } + + // 2. Auto-detect device model for friendly identification in ForkFolio settings + String defaultName = 'Mobile App'; + try { + final deviceInfo = DeviceInfoPlugin(); + if (Platform.isAndroid) { + final androidInfo = await deviceInfo.androidInfo; + defaultName = '${androidInfo.manufacturer} ${androidInfo.model}'.trim(); + } else if (Platform.isIOS) { + final iosInfo = await deviceInfo.iosInfo; + defaultName = iosInfo.utsname.machine; + } + } catch (_) {} + + _deviceNameController.text = defaultName; + } + + Future _submit() async { + if (!_formKey.currentState!.validate()) return; + + final success = await ref.read(authProvider.notifier).login( + serverUrl: _serverUrlController.text.trim(), + username: _usernameController.text.trim(), + password: _passwordController.text, + deviceName: _deviceNameController.text.trim(), + ); + + if (!success && mounted) { + final error = ref.read(authProvider).errorMessage ?? 'Login failed'; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(backgroundColor: AppTheme.danger, content: Text(error)), + ); + } + } + + @override + Widget build(BuildContext context) { + final authState = ref.watch(authProvider); + + return Scaffold( + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 24), + child: Form( + key: _formKey, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // App Logo & Heading + Center( + child: Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: AppTheme.primary.withOpacity(0.12), + shape: BoxShape.circle, + border: Border.all(color: AppTheme.primary.withOpacity(0.3), width: 1.5), + ), + child: const Icon(Icons.restaurant_menu_rounded, size: 48, color: AppTheme.primary), + ), + ), + const SizedBox(height: 20), + const Text( + 'ForkFolio Mobile', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 26, fontWeight: FontWeight.w800, letterSpacing: -0.5), + ), + const SizedBox(height: 6), + const Text( + 'Connect your mobile chef companion', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 14, color: AppTheme.textMuted), + ), + const SizedBox(height: 36), + + // Server URL Field + TextFormField( + controller: _serverUrlController, + keyboardType: TextInputType.url, + decoration: const InputDecoration( + labelText: 'Server Domain (HTTPS)', + hintText: 'https://recipes.yourdomain.com', + prefixIcon: Icon(Icons.dns_rounded, size: 20), + ), + validator: (v) { + if (v == null || v.trim().isEmpty) return 'Server URL is required'; + if (!v.trim().startsWith('http://') && !v.trim().startsWith('https://')) { + return 'Include https:// at the beginning'; + } + return null; + }, + ), + const SizedBox(height: 16), + + // Username Field + TextFormField( + controller: _usernameController, + decoration: const InputDecoration( + labelText: 'Username', + prefixIcon: Icon(Icons.person_outline_rounded, size: 20), + ), + validator: (v) => (v == null || v.trim().isEmpty) ? 'Username is required' : null, + ), + const SizedBox(height: 16), + + // Password Field + TextFormField( + controller: _passwordController, + obscureText: _obscurePassword, + decoration: InputDecoration( + labelText: 'Password', + prefixIcon: const Icon(Icons.lock_outline_rounded, size: 20), + suffixIcon: IconButton( + icon: Icon(_obscurePassword ? Icons.visibility_outlined : Icons.visibility_off_outlined, size: 20), + onPressed: () => setState(() => _obscurePassword = !_obscurePassword), + ), + ), + validator: (v) => (v == null || v.isEmpty) ? 'Password is required' : null, + ), + const SizedBox(height: 16), + + // Device Name Field + TextFormField( + controller: _deviceNameController, + decoration: const InputDecoration( + labelText: 'Device Friendly Name', + hintText: 'e.g. Pixel 8 Pro', + prefixIcon: Icon(Icons.smartphone_rounded, size: 20), + ), + validator: (v) => (v == null || v.trim().isEmpty) ? 'Device name is required' : null, + ), + const SizedBox(height: 28), + + // Submit Button + SizedBox( + height: 52, + child: ElevatedButton( + onPressed: authState.isLoading ? null : _submit, + child: authState.isLoading + ? const SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2.5), + ) + : const Text('Authorize & Link Device'), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/mobile/lib/features/cook_mode/cook_mode_screen.dart b/mobile/lib/features/cook_mode/cook_mode_screen.dart new file mode 100644 index 0000000..37f9dd1 --- /dev/null +++ b/mobile/lib/features/cook_mode/cook_mode_screen.dart @@ -0,0 +1,293 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:wakelock_plus/wakelock_plus.dart'; +import '../../core/constants/app_theme.dart'; +import '../../core/models/recipe.dart'; + +class CookModeScreen extends StatefulWidget { + final Recipe recipe; + + const CookModeScreen({super.key, required this.recipe}); + + @override + State createState() => _CookModeScreenState(); +} + +class _CookModeScreenState extends State { + int _currentStepIndex = 0; + double _scaleMultiplier = 1.0; + final PageController _pageController = PageController(); + + // Simple Timer State + Timer? _activeTimer; + int _timerSecondsRemaining = 0; + bool _isTimerRunning = false; + + @override + void initState() { + super.initState(); + // Keep screen on during cooking session so hands don't need to touch phone + WakelockPlus.enable(); + } + + @override + void dispose() { + WakelockPlus.disable(); + _activeTimer?.cancel(); + _pageController.dispose(); + super.dispose(); + } + + void _startTimer(int minutes) { + _activeTimer?.cancel(); + setState(() { + _timerSecondsRemaining = minutes * 60; + _isTimerRunning = true; + }); + + _activeTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (_timerSecondsRemaining <= 1) { + timer.cancel(); + setState(() { + _isTimerRunning = false; + _timerSecondsRemaining = 0; + }); + if (mounted) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: AppTheme.surfaceElevated, + title: const Text('Timer Finished! ⏰'), + content: Text('Your ${minutes}-minute timer has ended.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('OK', style: TextStyle(color: AppTheme.primary)), + ), + ], + ), + ); + } + } else { + setState(() { + _timerSecondsRemaining--; + }); + } + }); + } + + String _formatTimerTime(int totalSeconds) { + final m = totalSeconds ~/ 60; + final s = totalSeconds % 60; + return '${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}'; + } + + @override + Widget build(BuildContext context) { + final totalSteps = widget.recipe.instructions.isNotEmpty ? widget.recipe.instructions.length : 1; + + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + backgroundColor: Colors.black, + title: Text(widget.recipe.title, maxLines: 1, overflow: TextOverflow.ellipsis), + actions: [ + // Scale selector + PopupMenuButton( + initialValue: _scaleMultiplier, + tooltip: 'Scale Servings', + icon: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: AppTheme.surfaceElevated, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppTheme.border), + ), + child: Text( + '${_scaleMultiplier}x', + style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 13, color: AppTheme.primary), + ), + ), + onSelected: (val) => setState(() => _scaleMultiplier = val), + itemBuilder: (ctx) => const [ + PopupMenuItem(value: 0.5, child: Text('0.5x Servings')), + PopupMenuItem(value: 1.0, child: Text('1.0x (Original)')), + PopupMenuItem(value: 1.5, child: Text('1.5x Servings')), + PopupMenuItem(value: 2.0, child: Text('2.0x Double Servings')), + ], + ), + const SizedBox(width: 8), + ], + ), + body: SafeArea( + child: Column( + children: [ + // Top: Active Timer Pill (if active) + if (_isTimerRunning || _timerSecondsRemaining > 0) + Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + color: AppTheme.primary.withOpacity(0.2), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppTheme.primary), + ), + child: Row( + children: [ + const Icon(Icons.timer_rounded, color: AppTheme.primary), + const SizedBox(width: 10), + Text( + 'Timer: ${_formatTimerTime(_timerSecondsRemaining)}', + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w800, color: AppTheme.primary), + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.stop_circle_rounded, color: AppTheme.danger), + onPressed: () { + _activeTimer?.cancel(); + setState(() { + _isTimerRunning = false; + _timerSecondsRemaining = 0; + }); + }, + ), + ], + ), + ), + + // Step Progress Bar + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + child: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: (totalSteps > 0) ? (_currentStepIndex + 1) / totalSteps : 1.0, + backgroundColor: AppTheme.surfaceElevated, + color: AppTheme.primary, + minHeight: 6, + ), + ), + ), + + // Step Carousel (Large readable text) + Expanded( + child: widget.recipe.instructions.isEmpty + ? const Center(child: Text('No instructions found for this recipe.', style: TextStyle(color: AppTheme.textMuted))) + : PageView.builder( + controller: _pageController, + itemCount: totalSteps, + onPageChanged: (i) => setState(() => _currentStepIndex = i), + itemBuilder: (ctx, index) { + final stepText = widget.recipe.instructions[index]; + return Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'STEP ${index + 1} OF $totalSteps', + style: const TextStyle( + color: AppTheme.primary, + fontSize: 14, + fontWeight: FontWeight.w800, + letterSpacing: 1.2, + ), + ), + const SizedBox(height: 16), + Expanded( + child: SingleChildScrollView( + child: Text( + stepText, + style: const TextStyle( + color: Colors.white, + fontSize: 22, + height: 1.5, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ], + ), + ); + }, + ), + ), + + // Bottom Quick Timers & Nav Buttons + Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + decoration: const BoxDecoration( + color: AppTheme.surface, + border: Border(top: BorderSide(color: AppTheme.border)), + ), + child: Column( + children: [ + // Quick timer shortcuts + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('Quick Timer: ', style: TextStyle(fontSize: 12, color: AppTheme.textMuted)), + _buildTimerChip(5), + const SizedBox(width: 8), + _buildTimerChip(10), + const SizedBox(width: 8), + _buildTimerChip(15), + const SizedBox(width: 8), + _buildTimerChip(30), + ], + ), + const SizedBox(height: 16), + + // Nav Buttons + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: _currentStepIndex > 0 + ? () => _pageController.previousPage(duration: const Duration(milliseconds: 250), curve: Curves.easeInOut) + : null, + icon: const Icon(Icons.arrow_back_rounded), + label: const Text('Previous'), + ), + ), + const SizedBox(width: 14), + Expanded( + child: ElevatedButton.icon( + onPressed: _currentStepIndex < totalSteps - 1 + ? () => _pageController.nextPage(duration: const Duration(milliseconds: 250), curve: Curves.easeInOut) + : () => Navigator.pop(context), + icon: Icon(_currentStepIndex < totalSteps - 1 ? Icons.arrow_forward_rounded : Icons.check_circle_rounded), + label: Text(_currentStepIndex < totalSteps - 1 ? 'Next Step' : 'Finish'), + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildTimerChip(int minutes) { + return InkWell( + onTap: () => _startTimer(minutes), + borderRadius: BorderRadius.circular(8), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: AppTheme.surfaceElevated, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppTheme.border), + ), + child: Text( + '${minutes}m', + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: AppTheme.primary), + ), + ), + ); + } +} diff --git a/mobile/lib/features/library/recipes_screen.dart b/mobile/lib/features/library/recipes_screen.dart new file mode 100644 index 0000000..9255978 --- /dev/null +++ b/mobile/lib/features/library/recipes_screen.dart @@ -0,0 +1,310 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/constants/app_theme.dart'; +import '../../core/models/recipe.dart'; +import '../../core/providers.dart'; +import '../cook_mode/cook_mode_screen.dart'; +import '../scanner/scanner_screen.dart'; + +class RecipesScreen extends ConsumerStatefulWidget { + const RecipesScreen({super.key}); + + @override + ConsumerState createState() => _RecipesScreenState(); +} + +class _RecipesScreenState extends ConsumerState { + final TextEditingController _searchController = TextEditingController(); + String _searchQuery = ''; + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final recipesAsync = ref.watch(recipeListProvider); + final user = ref.watch(authProvider).user; + + return Scaffold( + appBar: AppBar( + title: Row( + children: [ + const Icon(Icons.fork_right_rounded, color: AppTheme.primary, size: 24), + const SizedBox(width: 8), + const Text('ForkFolio'), + ], + ), + actions: [ + IconButton( + tooltip: 'Camera Scanner', + icon: const Icon(Icons.camera_alt_outlined), + onPressed: () { + Navigator.push(context, MaterialPageRoute(builder: (_) => const ScannerScreen())); + }, + ), + IconButton( + tooltip: 'Logout', + icon: const Icon(Icons.logout_rounded), + onPressed: () => ref.read(authProvider.notifier).logout(), + ), + ], + ), + body: RefreshIndicator( + color: AppTheme.primary, + onRefresh: () => ref.read(recipeListProvider.notifier).loadRecipes(), + child: Column( + children: [ + // Search Bar + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: TextField( + controller: _searchController, + onChanged: (val) => setState(() => _searchQuery = val.trim().toLowerCase()), + decoration: InputDecoration( + hintText: 'Search recipes, ingredients...', + prefixIcon: const Icon(Icons.search_rounded, size: 20), + suffixIcon: _searchQuery.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear_rounded, size: 18), + onPressed: () { + _searchController.clear(); + setState(() => _searchQuery = ''); + }, + ) + : null, + ), + ), + ), + + // Recipe List + Expanded( + child: recipesAsync.when( + loading: () => const Center( + child: CircularProgressIndicator(color: AppTheme.primary), + ), + error: (err, _) => Center( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.cloud_off_rounded, color: AppTheme.textMuted, size: 48), + const SizedBox(height: 12), + Text( + 'Could not load recipes: $err', + textAlign: TextAlign.center, + style: const TextStyle(color: AppTheme.textMuted), + ), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: () => ref.read(recipeListProvider.notifier).loadRecipes(), + icon: const Icon(Icons.refresh_rounded), + label: const Text('Retry'), + ), + ], + ), + ), + ), + data: (recipes) { + final filtered = recipes.where((r) { + if (_searchQuery.isEmpty) return true; + final matchesTitle = r.title.toLowerCase().contains(_searchQuery); + final matchesIngredient = r.ingredients.any((i) => i.toLowerCase().contains(_searchQuery)); + return matchesTitle || matchesIngredient; + }).toList(); + + if (filtered.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.menu_book_rounded, color: AppTheme.textDisabled.withOpacity(0.5), size: 64), + const SizedBox(height: 14), + Text( + _searchQuery.isEmpty ? 'Your Cookbook is Empty' : 'No recipes match "$_searchQuery"', + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppTheme.textMuted), + ), + const SizedBox(height: 6), + const Text( + 'Share a link from Instagram or tap the camera icon to scan a recipe.', + style: TextStyle(fontSize: 12, color: AppTheme.textDisabled), + ), + ], + ), + ); + } + + return ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + itemCount: filtered.length, + separatorBuilder: (_, __) => const SizedBox(height: 12), + itemBuilder: (ctx, index) { + final recipe = filtered[index]; + return _buildRecipeCard(recipe); + }, + ); + }, + ), + ), + ], + ), + ), + floatingActionButton: FloatingActionButton.extended( + backgroundColor: AppTheme.primary, + onPressed: () { + Navigator.push(context, MaterialPageRoute(builder: (_) => const ScannerScreen())); + }, + icon: const Icon(Icons.camera_enhance_rounded, color: Colors.white), + label: const Text('Scan Recipe', style: TextStyle(color: Colors.white, fontWeight: FontWeight.w700)), + ), + ); + } + + Widget _buildRecipeCard(Recipe recipe) { + return Card( + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => _openRecipeDetail(recipe), + child: Row( + children: [ + // Thumbnail + Container( + width: 100, + height: 100, + color: AppTheme.surfaceElevated, + child: recipe.image != null && recipe.image!.isNotEmpty + ? (recipe.image!.startsWith('data:image') + ? Image.memory(base64Decode(recipe.image!.split(',').last), fit: BoxFit.cover) + : Image.network( + recipe.image!, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => const Icon(Icons.restaurant_rounded, color: AppTheme.textDisabled), + )) + : const Icon(Icons.restaurant_rounded, color: AppTheme.textDisabled, size: 32), + ), + + // Info + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + recipe.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700, color: AppTheme.textMain), + ), + const SizedBox(height: 8), + Row( + children: [ + if (recipe.cookTime != null && recipe.cookTime!.isNotEmpty) ...[ + const Icon(Icons.schedule_rounded, size: 14, color: AppTheme.primary), + const SizedBox(width: 4), + Text(recipe.cookTime!, style: const TextStyle(fontSize: 12, color: AppTheme.textMuted)), + const SizedBox(width: 12), + ], + if (recipe.servings != null && recipe.servings!.isNotEmpty) ...[ + const Icon(Icons.people_outline_rounded, size: 14, color: AppTheme.secondary), + const SizedBox(width: 4), + Text(recipe.servings!, style: const TextStyle(fontSize: 12, color: AppTheme.textMuted)), + ], + ], + ), + ], + ), + ), + ), + + // Action + IconButton( + icon: const Icon(Icons.outdoor_grill_rounded, color: AppTheme.primary), + tooltip: 'Start Cook Mode', + onPressed: () { + Navigator.push(context, MaterialPageRoute(builder: (_) => CookModeScreen(recipe: recipe))); + }, + ), + ], + ), + ), + ); + } + + void _openRecipeDetail(Recipe recipe) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (ctx) => DraggableScrollableSheet( + initialChildSize: 0.75, + maxChildSize: 0.95, + minChildSize: 0.5, + builder: (_, scrollController) => Container( + decoration: const BoxDecoration( + color: AppTheme.surface, + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + child: ListView( + controller: scrollController, + children: [ + Center( + child: Container( + width: 44, + height: 4, + decoration: BoxDecoration(color: AppTheme.border, borderRadius: BorderRadius.circular(2)), + ), + ), + const SizedBox(height: 16), + Text(recipe.title, style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w800)), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + height: 48, + child: ElevatedButton.icon( + onPressed: () { + Navigator.pop(ctx); + Navigator.push(context, MaterialPageRoute(builder: (_) => CookModeScreen(recipe: recipe))); + }, + icon: const Icon(Icons.outdoor_grill_rounded), + label: const Text('Start Cook Mode'), + ), + ), + const SizedBox(height: 24), + const Text('Ingredients', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: AppTheme.primary)), + const SizedBox(height: 8), + ...recipe.ingredients.map((ing) => Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('• ', style: TextStyle(color: AppTheme.primary, fontWeight: FontWeight.w800)), + Expanded(child: Text(ing, style: const TextStyle(fontSize: 14))), + ], + ), + )), + const SizedBox(height: 24), + const Text('Instructions', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: AppTheme.primary)), + const SizedBox(height: 8), + ...recipe.instructions.asMap().entries.map((entry) => Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('${entry.key + 1}. ', style: const TextStyle(color: AppTheme.primary, fontWeight: FontWeight.w700)), + Expanded(child: Text(entry.value, style: const TextStyle(fontSize: 14, height: 1.4))), + ], + ), + )), + ], + ), + ), + ), + ); + } +} diff --git a/mobile/lib/features/scanner/scanner_screen.dart b/mobile/lib/features/scanner/scanner_screen.dart new file mode 100644 index 0000000..9d2fd77 --- /dev/null +++ b/mobile/lib/features/scanner/scanner_screen.dart @@ -0,0 +1,150 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:image_picker/image_picker.dart'; +import '../../core/constants/app_theme.dart'; +import '../../core/models/recipe.dart'; +import '../../core/providers.dart'; +import '../share_receiver/quick_share_sheet.dart'; + +class ScannerScreen extends ConsumerStatefulWidget { + const ScannerScreen({super.key}); + + @override + ConsumerState createState() => _ScannerScreenState(); +} + +class _ScannerScreenState extends ConsumerState { + final ImagePicker _picker = ImagePicker(); + bool _isProcessing = false; + + Future _captureFromCamera() async { + final XFile? photo = await _picker.pickImage( + source: ImageSource.camera, + maxWidth: 1600, + maxHeight: 1600, + imageQuality: 85, + ); + if (photo != null) { + _processImage(photo.path); + } + } + + Future _pickFromGallery() async { + final XFile? image = await _picker.pickImage( + source: ImageSource.gallery, + maxWidth: 1600, + maxHeight: 1600, + imageQuality: 85, + ); + if (image != null) { + _processImage(image.path); + } + } + + Future _processImage(String path) async { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (ctx) => QuickShareSheet(imagePath: path), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Scan Recipe Photo'), + ), + body: Stack( + children: [ + // Viewfinder guide + Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + height: 320, + width: double.infinity, + decoration: BoxDecoration( + color: AppTheme.surfaceElevated.withOpacity(0.5), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: AppTheme.primary, width: 2), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppTheme.primary.withOpacity(0.12), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.camera_enhance_rounded, + color: AppTheme.primary, + size: 44, + ), + ), + const SizedBox(height: 16), + const Text( + 'Align Cookbook or Recipe Card', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: AppTheme.textMain, + ), + ), + const SizedBox(height: 6), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 20), + child: Text( + 'Our multimodal Gemini AI automatically parses ingredients, instructions, and cooking times.', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 12, color: AppTheme.textMuted), + ), + ), + ], + ), + ), + const SizedBox(height: 32), + + // Shutter actions + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + OutlinedButton.icon( + onPressed: _pickFromGallery, + icon: const Icon(Icons.photo_library_rounded), + label: const Text('Gallery'), + ), + const SizedBox(width: 16), + ElevatedButton.icon( + onPressed: _captureFromCamera, + icon: const Icon(Icons.camera_alt_rounded), + label: const Text('Take Photo'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + ), + ), + ], + ), + ], + ), + ), + + if (_isProcessing) + Container( + color: Colors.black54, + child: const Center( + child: CircularProgressIndicator(color: AppTheme.primary), + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/features/share_receiver/quick_share_sheet.dart b/mobile/lib/features/share_receiver/quick_share_sheet.dart new file mode 100644 index 0000000..00eb406 --- /dev/null +++ b/mobile/lib/features/share_receiver/quick_share_sheet.dart @@ -0,0 +1,391 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/constants/app_theme.dart'; +import '../../core/models/recipe.dart'; +import '../../core/providers.dart'; + +class QuickShareSheet extends ConsumerStatefulWidget { + final String? url; + final String? rawCaption; + final String? imagePath; + + const QuickShareSheet({ + super.key, + this.url, + this.rawCaption, + this.imagePath, + }); + + @override + ConsumerState createState() => _QuickShareSheetState(); +} + +class _QuickShareSheetState extends ConsumerState { + bool _isLoading = true; + String? _errorMessage; + Recipe? _scrapedRecipe; + + late TextEditingController _titleController; + late TextEditingController _servingsController; + late TextEditingController _prepTimeController; + late TextEditingController _cookTimeController; + + @override + void initState() { + super.initState(); + _titleController = TextEditingController(); + _servingsController = TextEditingController(); + _prepTimeController = TextEditingController(); + _cookTimeController = TextEditingController(); + _executeIngestion(); + } + + @override + void dispose() { + _titleController.dispose(); + _servingsController.dispose(); + _prepTimeController.dispose(); + _cookTimeController.dispose(); + super.dispose(); + } + + Future _executeIngestion() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + final api = ref.read(apiClientProvider); + + try { + Map data; + + if (widget.imagePath != null) { + // Shared photo ingestion via Gemini Multimodal endpoint + final bytes = await File(widget.imagePath!).readAsBytes(); + final base64Image = base64Encode(bytes); + data = await api.scanRecipePhoto(imageBase64: base64Image); + } else { + // Shared link or social post caption ingestion + data = await api.scrapeRecipe( + url: widget.url, + rawCaption: widget.rawCaption, + ); + } + + final recipe = Recipe.fromJson(data); + setState(() { + _scrapedRecipe = recipe; + _titleController.text = recipe.title; + _servingsController.text = recipe.servings ?? ''; + _prepTimeController.text = recipe.prepTime ?? ''; + _cookTimeController.text = recipe.cookTime ?? ''; + _isLoading = false; + }); + } catch (e) { + setState(() { + _errorMessage = e.toString(); + _isLoading = false; + }); + } + } + + Future _handleSave() async { + if (_scrapedRecipe == null) return; + + final updated = _scrapedRecipe!.copyWith( + title: _titleController.text.trim().isEmpty ? 'Untitled Recipe' : _titleController.text.trim(), + servings: _servingsController.text.trim(), + prepTime: _prepTimeController.text.trim(), + cookTime: _cookTimeController.text.trim(), + sourceUrl: widget.url ?? _scrapedRecipe!.sourceUrl, + ); + + try { + await ref.read(recipeListProvider.notifier).addOrUpdate(updated); + if (mounted) { + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + backgroundColor: AppTheme.secondary, + content: Row( + children: [ + const Icon(Icons.check_circle_rounded, color: Colors.white), + const SizedBox(width: 10), + Expanded(child: Text('Saved "${updated.title}" to ForkFolio!')), + ], + ), + ), + ); + } + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(backgroundColor: AppTheme.danger, content: Text('Save error: $e')), + ); + } + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: const BoxDecoration( + color: AppTheme.surface, + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + padding: EdgeInsets.only( + top: 16, + left: 20, + right: 20, + bottom: MediaQuery.of(context).viewInsets.bottom + 24, + ), + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.88, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Drag handle + Container( + width: 44, + height: 4, + decoration: BoxDecoration( + color: AppTheme.border, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 16), + + // Header + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppTheme.primary.withOpacity(0.15), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon(Icons.fork_right_rounded, color: AppTheme.primary, size: 20), + ), + const SizedBox(width: 10), + const Text( + 'Save to ForkFolio', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700), + ), + ], + ), + IconButton( + icon: const Icon(Icons.close_rounded, color: AppTheme.textMuted), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + const Divider(color: AppTheme.border, height: 24), + + // Content body + Expanded( + child: _isLoading + ? _buildLoadingState() + : _errorMessage != null + ? _buildErrorState() + : _buildRecipePreview(), + ), + + // Action button + if (!_isLoading && _scrapedRecipe != null) ...[ + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + height: 50, + child: ElevatedButton.icon( + onPressed: _handleSave, + icon: const Icon(Icons.bookmark_add_rounded), + label: const Text('Save to My Cookbook'), + ), + ), + ], + ], + ), + ); + } + + Widget _buildLoadingState() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox( + width: 48, + height: 48, + child: CircularProgressIndicator(color: AppTheme.primary, strokeWidth: 3), + ), + const SizedBox(height: 20), + Text( + widget.imagePath != null + ? 'Analyzing recipe photo with Gemini Vision...' + : 'Scraping recipe details...', + style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: AppTheme.textMain), + ), + const SizedBox(height: 6), + Text( + widget.url ?? widget.rawCaption ?? 'Ingesting shared content', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 12, color: AppTheme.textMuted), + ), + ], + ), + ); + } + + Widget _buildErrorState() { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline_rounded, color: AppTheme.danger, size: 48), + const SizedBox(height: 14), + const Text( + 'Could not extract recipe', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: AppTheme.textMain), + ), + const SizedBox(height: 8), + Text( + _errorMessage ?? 'Unknown error occurred.', + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 13, color: AppTheme.textMuted), + ), + const SizedBox(height: 20), + ElevatedButton.icon( + onPressed: _executeIngestion, + icon: const Icon(Icons.refresh_rounded), + label: const Text('Retry Scrape'), + ), + ], + ), + ), + ); + } + + Widget _buildRecipePreview() { + final recipe = _scrapedRecipe!; + + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Hero cover preview if present + if (recipe.image != null && recipe.image!.isNotEmpty) ...[ + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: recipe.image!.startsWith('data:image') + ? Image.memory( + base64Decode(recipe.image!.split(',').last), + height: 160, + width: double.infinity, + fit: BoxFit.cover, + ) + : Image.network( + recipe.image!, + height: 160, + width: double.infinity, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => const SizedBox(), + ), + ), + const SizedBox(height: 16), + ], + + // Title field + const Text('Recipe Title', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: AppTheme.textMuted)), + const SizedBox(height: 6), + TextField( + controller: _titleController, + style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16), + decoration: const InputDecoration(hintText: 'Recipe title'), + ), + const SizedBox(height: 14), + + // Time & Servings Row + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Prep Time', style: TextStyle(fontSize: 12, color: AppTheme.textMuted)), + const SizedBox(height: 4), + TextField( + controller: _prepTimeController, + decoration: const InputDecoration(hintText: 'e.g. 15m', isDense: true), + ), + ], + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Cook Time', style: TextStyle(fontSize: 12, color: AppTheme.textMuted)), + const SizedBox(height: 4), + TextField( + controller: _cookTimeController, + decoration: const InputDecoration(hintText: 'e.g. 30m', isDense: true), + ), + ], + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Servings', style: TextStyle(fontSize: 12, color: AppTheme.textMuted)), + const SizedBox(height: 4), + TextField( + controller: _servingsController, + decoration: const InputDecoration(hintText: 'e.g. 4', isDense: true), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 18), + + // Ingredients Count Pill + Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: AppTheme.surfaceElevated, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: AppTheme.border), + ), + child: Row( + children: [ + const Icon(Icons.egg_alt_outlined, color: AppTheme.secondary, size: 18), + const SizedBox(width: 10), + Text( + '${recipe.ingredients.length} Ingredients Detected', + style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13), + ), + const Spacer(), + Text( + '${recipe.instructions.length} Steps', + style: const TextStyle(color: AppTheme.textMuted, fontSize: 13), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/features/share_receiver/share_receiver_service.dart b/mobile/lib/features/share_receiver/share_receiver_service.dart new file mode 100644 index 0000000..d48386d --- /dev/null +++ b/mobile/lib/features/share_receiver/share_receiver_service.dart @@ -0,0 +1,77 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:receive_sharing_intent/receive_sharing_intent.dart'; +import 'quick_share_sheet.dart'; + +class ShareReceiverService { + StreamSubscription? _intentSub; + + void init(BuildContext context) { + // 1. Listen to shared text/links while app is in memory + _intentSub = ReceiveSharingIntent.getTextStream().listen((String value) { + if (value.isNotEmpty) { + _handleSharedText(context, value); + } + }, onError: (err) { + debugPrint("getLinkStream error: $err"); + }); + + // 2. Get the shared text/link when the app is launched from closed state + ReceiveSharingIntent.getInitialText().then((String? value) { + if (value != null && value.isNotEmpty) { + _handleSharedText(context, value); + } + }); + + // 3. Listen to shared files/images (e.g. sharing photo from Gallery) + ReceiveSharingIntent.getImageStream().listen((List value) { + if (value.isNotEmpty) { + _handleSharedFiles(context, value); + } + }, onError: (err) { + debugPrint("getImageStream error: $err"); + }); + + ReceiveSharingIntent.getInitialImage().then((List value) { + if (value.isNotEmpty) { + _handleSharedFiles(context, value); + } + }); + } + + void _handleSharedText(BuildContext context, String rawText) { + // Sanitize and extract URL from messy text (e.g. Instagram caption + link) + final urlRegex = RegExp(r'https?://[^\s]+'); + final match = urlRegex.firstMatch(rawText); + final url = match?.group(0); + final rawCaption = rawText.replaceFirst(url ?? '', '').trim(); + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (ctx) => QuickShareSheet( + url: url, + rawCaption: rawCaption.isNotEmpty ? rawCaption : null, + ), + ); + } + + void _handleSharedFiles(BuildContext context, List files) { + if (files.isEmpty) return; + final firstFile = files.first; + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (ctx) => QuickShareSheet( + imagePath: firstFile.path, + ), + ); + } + + void dispose() { + _intentSub?.cancel(); + } +} diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart new file mode 100644 index 0000000..089767e --- /dev/null +++ b/mobile/lib/main.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'core/constants/app_theme.dart'; +import 'core/providers.dart'; +import 'features/auth/login_screen.dart'; +import 'features/library/recipes_screen.dart'; +import 'features/share_receiver/share_receiver_service.dart'; + +void main() { + WidgetsFlutterBinding.ensureInitialized(); + SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + ]); + + runApp(const ProviderScope(child: ForkFolioApp())); +} + +class ForkFolioApp extends ConsumerStatefulWidget { + const ForkFolioApp({super.key}); + + @override + ConsumerState createState() => _ForkFolioAppState(); +} + +class _ForkFolioAppState extends ConsumerState { + final ShareReceiverService _shareReceiver = ShareReceiverService(); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _shareReceiver.init(context); + }); + } + + @override + void dispose() { + _shareReceiver.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final authState = ref.watch(authProvider); + + return MaterialApp( + title: 'ForkFolio', + debugShowCheckedModeBanner: false, + theme: AppTheme.darkTheme, + home: authState.isAuthenticated ? const RecipesScreen() : const LoginScreen(), + ); + } +} diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml new file mode 100644 index 0000000..cac0d95 --- /dev/null +++ b/mobile/pubspec.yaml @@ -0,0 +1,49 @@ +name: forkfolio_mobile +description: "ForkFolio Mobile - Cross-platform recipe management, share target ingestion, and cooking assistant" +publish_to: 'none' +version: 1.0.0+1 + +environment: + sdk: '>=3.2.0 <4.0.0' + +dependencies: + flutter: + sdk: flutter + + # State Management & DI + flutter_riverpod: ^2.5.1 + + # Networking & Serialization + dio: ^5.4.3+1 + json_annotation: ^4.9.0 + + # Secure Storage (Hardware Keystore on Android, Keychain on iOS) + flutter_secure_storage: ^9.2.2 + + # Share Target (Android ACTION_SEND & iOS Share Extension) + receive_sharing_intent: ^1.4.5 + + # Camera & Image Ingestion for Cookbook OCR + camera: ^0.10.5+9 + image_picker: ^1.1.1 + image_cropper: ^5.0.1 + + # Cook Mode (Keep screen awake while hands are messy) + wakelock_plus: ^1.2.5 + + # Device Identification for Persistent Mobile Tokens + device_info_plus: ^10.1.0 + + # UI, Icons, Typography + cupertino_icons: ^1.0.6 + google_fonts: ^6.2.1 + intl: ^0.19.0 + uuid: ^4.4.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^3.0.0 + +flutter: + uses-material-design: true