Add ForkFolio mobile app and GitLab CI pipeline to automatically build release APK

This commit is contained in:
Chris 2026-11-01 13:15:21 -05:00
parent 9c5eae20f2
commit a39ad5e963
21 changed files with 2442 additions and 0 deletions

23
.gitlab-ci.yml Normal file
View file

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

59
mobile/README.md Normal file
View file

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

View file

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

View file

@ -0,0 +1,58 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.forkfolio.mobile">
<!-- Permissions for Network, Camera Scanning, and Kitchen Cook Mode -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application
android:label="ForkFolio"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Standard Main Launcher Intent Filter -->
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<!-- 1. SHARE TARGET: Text & Links (Instagram, TikTok, Chrome, Reddit, Facebook) -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
<!-- 2. SHARE TARGET: Images (Cookbook photo shares, recipe card screenshots) -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
<!-- 3. SHARE TARGET: Multiple Images -->
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
</activity>
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
</manifest>

View file

@ -0,0 +1,6 @@
package com.forkfolio.mobile
import io.flutter.embedding.android.FlutterActivity
class MainActivity: FlutterActivity() {
}

View file

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

View file

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

View file

@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>ForkFolio</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>forkfolio_mobile</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<!-- Camera & Photo Library Permissions for Recipe Scanning -->
<key>NSCameraUsageDescription</key>
<string>ForkFolio needs camera access to scan recipe photos from cookbooks, recipe cards, and packaging.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>ForkFolio needs photo library access to import recipe screenshots and food photos.</string>
<!-- App Group for iOS Share Extension -->
<key>AppGroupId</key>
<string>group.com.forkfolio.mobile</string>
</dict>
</plist>

View file

@ -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<Map<String, dynamic>> 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<String, dynamic>;
final token = data['token'] as String;
final expiresAt = data['expiresAt'] as String;
final user = data['user'] as Map<String, dynamic>;
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<List<Recipe>> fetchRecipes() async {
try {
final response = await _dio.get('/api/recipes');
final list = response.data as List<dynamic>;
return list.map((item) => Recipe.fromJson(item as Map<String, dynamic>)).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<Map<String, dynamic>> 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<String, dynamic>;
} 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<Map<String, dynamic>> 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<String, dynamic>;
} 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<Recipe> 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<String, dynamic>);
} 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<void> 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<void> logout() async {
try {
await _dio.post('/api/auth/logout');
} catch (_) {
// Continue clearing local storage even if network request fails
} finally {
await _storage.clearSession();
}
}
}

View file

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

View file

@ -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<String> images;
final List<String> ingredients;
final List<String> 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<String>? images,
List<String>? ingredients,
List<String>? instructions,
this.sourceUrl,
this.shareToken,
this.createdAt,
this.updatedAt,
}) : images = images ?? [],
ingredients = ingredients ?? [],
instructions = instructions ?? [];
factory Recipe.fromJson(Map<String, dynamic> json) {
List<String> 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<String, dynamic> 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<String>? images,
List<String>? ingredients,
List<String>? 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,
);
}
}

View file

@ -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<SecureStorageService>((ref) {
return SecureStorageService();
});
final apiClientProvider = Provider<ApiClient>((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<String, dynamic>? 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<String, dynamic> user) =>
AuthState(isAuthenticated: true, user: user);
factory AuthState.unauthenticated([String? error]) =>
AuthState(isAuthenticated: false, errorMessage: error);
}
class AuthNotifier extends StateNotifier<AuthState> {
final ApiClient _api;
final SecureStorageService _storage;
AuthNotifier(this._api, this._storage) : super(AuthState.initial()) {
checkInitialAuth();
}
Future<void> 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<bool> 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<String, dynamic>;
state = AuthState.authenticated(user);
return true;
} catch (e) {
state = AuthState.unauthenticated(e.toString());
return false;
}
}
Future<void> logout() async {
await _api.logout();
state = AuthState.unauthenticated();
}
}
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
final api = ref.watch(apiClientProvider);
final storage = ref.watch(secureStorageProvider);
return AuthNotifier(api, storage);
});
// Recipe List State Notifier
class RecipeListNotifier extends StateNotifier<AsyncValue<List<Recipe>>> {
final ApiClient _api;
RecipeListNotifier(this._api) : super(const AsyncValue.loading()) {
loadRecipes();
}
Future<void> 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<void> 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<void> 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<RecipeListNotifier, AsyncValue<List<Recipe>>>((ref) {
final api = ref.watch(apiClientProvider);
return RecipeListNotifier(api);
});

View file

@ -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<void> saveSession({
required String serverUrl,
required String token,
required Map<String, dynamic> 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<String?> getServerUrl() async => await _storage.read(key: _keyServerUrl);
Future<String?> getToken() async => await _storage.read(key: _keyToken);
Future<String?> getExpiresAt() async => await _storage.read(key: _keyExpiresAt);
Future<Map<String, dynamic>?> getUser() async {
final raw = await _storage.read(key: _keyUser);
if (raw == null) return null;
try {
return jsonDecode(raw) as Map<String, dynamic>;
} catch (_) {
return null;
}
}
Future<bool> 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<void> clearSession() async {
await _storage.delete(key: _keyToken);
await _storage.delete(key: _keyUser);
await _storage.delete(key: _keyExpiresAt);
// Keep serverUrl for convenience on next login
}
}

View file

@ -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<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends ConsumerState<LoginScreen> {
final _formKey = GlobalKey<FormState>();
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<void> _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<void> _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'),
),
),
],
),
),
),
),
),
);
}
}

View file

@ -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<CookModeScreen> createState() => _CookModeScreenState();
}
class _CookModeScreenState extends State<CookModeScreen> {
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<double>(
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),
),
),
);
}
}

View file

@ -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<RecipesScreen> createState() => _RecipesScreenState();
}
class _RecipesScreenState extends ConsumerState<RecipesScreen> {
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))),
],
),
)),
],
),
),
),
);
}
}

View file

@ -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<ScannerScreen> createState() => _ScannerScreenState();
}
class _ScannerScreenState extends ConsumerState<ScannerScreen> {
final ImagePicker _picker = ImagePicker();
bool _isProcessing = false;
Future<void> _captureFromCamera() async {
final XFile? photo = await _picker.pickImage(
source: ImageSource.camera,
maxWidth: 1600,
maxHeight: 1600,
imageQuality: 85,
);
if (photo != null) {
_processImage(photo.path);
}
}
Future<void> _pickFromGallery() async {
final XFile? image = await _picker.pickImage(
source: ImageSource.gallery,
maxWidth: 1600,
maxHeight: 1600,
imageQuality: 85,
);
if (image != null) {
_processImage(image.path);
}
}
Future<void> _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),
),
),
],
),
);
}
}

View file

@ -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<QuickShareSheet> createState() => _QuickShareSheetState();
}
class _QuickShareSheetState extends ConsumerState<QuickShareSheet> {
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<void> _executeIngestion() async {
setState(() {
_isLoading = true;
_errorMessage = null;
});
final api = ref.read(apiClientProvider);
try {
Map<String, dynamic> 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<void> _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),
),
],
),
),
],
),
);
}
}

View file

@ -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<SharedMediaFile> value) {
if (value.isNotEmpty) {
_handleSharedFiles(context, value);
}
}, onError: (err) {
debugPrint("getImageStream error: $err");
});
ReceiveSharingIntent.getInitialImage().then((List<SharedMediaFile> 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<SharedMediaFile> 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();
}
}

55
mobile/lib/main.dart Normal file
View file

@ -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<ForkFolioApp> createState() => _ForkFolioAppState();
}
class _ForkFolioAppState extends ConsumerState<ForkFolioApp> {
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(),
);
}
}

49
mobile/pubspec.yaml Normal file
View file

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