mirror of
https://gitlab.com/Zer0interrupt/recipe-manager.git
synced 2026-11-01 23:24:11 -05:00
Add ForkFolio mobile app and GitLab CI pipeline to automatically build release APK
This commit is contained in:
parent
9c5eae20f2
commit
a39ad5e963
21 changed files with 2442 additions and 0 deletions
23
.gitlab-ci.yml
Normal file
23
.gitlab-ci.yml
Normal 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
59
mobile/README.md
Normal 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.
|
||||||
64
mobile/android/app/build.gradle
Normal file
64
mobile/android/app/build.gradle
Normal 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 {}
|
||||||
58
mobile/android/app/src/main/AndroidManifest.xml
Normal file
58
mobile/android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
package com.forkfolio.mobile
|
||||||
|
|
||||||
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
|
||||||
|
class MainActivity: FlutterActivity() {
|
||||||
|
}
|
||||||
18
mobile/android/build.gradle
Normal file
18
mobile/android/build.gradle
Normal 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
|
||||||
|
}
|
||||||
25
mobile/android/settings.gradle
Normal file
25
mobile/android/settings.gradle
Normal 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"
|
||||||
48
mobile/ios/Runner/Info.plist
Normal file
48
mobile/ios/Runner/Info.plist
Normal 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>
|
||||||
205
mobile/lib/core/api/api_client.dart
Normal file
205
mobile/lib/core/api/api_client.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
90
mobile/lib/core/constants/app_theme.dart
Normal file
90
mobile/lib/core/constants/app_theme.dart
Normal 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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
117
mobile/lib/core/models/recipe.dart
Normal file
117
mobile/lib/core/models/recipe.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
135
mobile/lib/core/providers.dart
Normal file
135
mobile/lib/core/providers.dart
Normal 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);
|
||||||
|
});
|
||||||
69
mobile/lib/core/storage/secure_storage_service.dart
Normal file
69
mobile/lib/core/storage/secure_storage_service.dart
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
200
mobile/lib/features/auth/login_screen.dart
Normal file
200
mobile/lib/features/auth/login_screen.dart
Normal 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'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
293
mobile/lib/features/cook_mode/cook_mode_screen.dart
Normal file
293
mobile/lib/features/cook_mode/cook_mode_screen.dart
Normal 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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
310
mobile/lib/features/library/recipes_screen.dart
Normal file
310
mobile/lib/features/library/recipes_screen.dart
Normal 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))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
150
mobile/lib/features/scanner/scanner_screen.dart
Normal file
150
mobile/lib/features/scanner/scanner_screen.dart
Normal 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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
391
mobile/lib/features/share_receiver/quick_share_sheet.dart
Normal file
391
mobile/lib/features/share_receiver/quick_share_sheet.dart
Normal 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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
55
mobile/lib/main.dart
Normal 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
49
mobile/pubspec.yaml
Normal 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
|
||||||
Loading…
Reference in a new issue