Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,10 @@ jobs:
- name: Generate ObjectBox bindings
run: dart run build_runner build --delete-conflicting-outputs

- name: Build release APK
run: flutter build apk --release --split-per-abi --target-platform android-arm64,android-x64
- name: Build release APKs
run: |
flutter build apk --flavor lite -t lib/main_lite.dart --release --split-per-abi --target-platform android-arm64,android-x64
flutter build apk --flavor full -t lib/main_full.dart --release --split-per-abi --target-platform android-arm64,android-x64

- name: Rename APKs with version
run: |
Expand Down
31 changes: 26 additions & 5 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,7 @@ android {
}

defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "dev.koshika.koshika"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
Expand All @@ -32,8 +29,6 @@ android {

buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
isMinifyEnabled = true
isShrinkResources = true
Expand All @@ -48,6 +43,32 @@ android {
excludes += "**/libimagegenerator_gpu.so"
}
}

flavorDimensions += "appType"
productFlavors {
create("lite") {
dimension = "appType"
applicationIdSuffix = ".lite"
versionNameSuffix = "-lite"
}
create("full") {
dimension = "appType"
}
}
}

// Strip llama.cpp native libs from the lite flavor APK.
// This runs after packaging to remove AI inference libraries,
// keeping the lite APK small (~15MB smaller).
androidComponents {
onVariants(selector().withFlavor("appType" to "lite")) { variant ->
variant.packaging.jniLibs.excludes.addAll(listOf(
"**/libllama.so",
"**/libggml*.so",
"**/libmtmd.so",
"**/libllamadart.so",
))
}
}

flutter {
Expand Down
4 changes: 0 additions & 4 deletions android/app/proguard-rules.pro
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
# MediaPipe optional proto classes (not needed at runtime)
-dontwarn com.google.mediapipe.proto.**
-dontwarn com.google.mediapipe.framework.GraphProfiler

# ML Kit optional language model classes (only Latin script is used)
-dontwarn com.google.mlkit.vision.text.chinese.**
-dontwarn com.google.mlkit.vision.text.devanagari.**
Expand Down
5 changes: 2 additions & 3 deletions lib/constants/ai_prompts.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,11 @@
/// Keeping prompts here (rather than inlined in service files) makes them
/// easy to review, iterate, and A/B test without touching service logic.
abstract final class AiPrompts {
/// System prompt for the Gemma chat model.
/// System prompt injected before every user query.
///
/// Injected as a [Message.systemInfo] turn before every user query.
/// Lab data context is added separately to the user turn so that
/// small (1B-param) models cannot ignore it.
static const String gemmaSystemPrompt = '''
static const String systemPrompt = '''
You are Koshika AI, a helpful on-device health assistant built into the Koshika app. You help users understand their lab report results.

CRITICAL RULES:
Expand Down
101 changes: 72 additions & 29 deletions lib/main.dart
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import 'dart:ui';

import 'package:flutter/material.dart';
import 'package:flutter_gemma/flutter_gemma.dart';

import 'screens/chat_screen.dart';
import 'screens/dashboard_screen.dart';
Expand All @@ -11,25 +10,67 @@ import 'screens/settings_screen.dart';
import 'screens/splash_screen.dart';
import 'services/objectbox_store.dart';
import 'services/biomarker_dictionary.dart';
import 'services/embedding_service.dart';
import 'services/gemma_service.dart';
import 'services/llm_embedding_service.dart';
import 'services/llm_service.dart';
import 'services/vector_store_service.dart';
import 'theme/app_colors.dart';
import 'theme/koshika_design_system.dart';

/// Global references — initialized in SplashScreen before navigation.
late ObjectBoxStore objectbox;
late BiomarkerDictionary biomarkerDictionary;
late GemmaService gemmaService;
late EmbeddingService embeddingService;
late LlmService llmService;
late LlmEmbeddingService embeddingService;
late VectorStoreService vectorStoreService;
Future<void>? _embeddingMigrationTask;

Future<void> main() async {
/// Whether AI features are enabled. Set by the entry point
/// (main_full.dart vs main_lite.dart).
bool kAiEnabled = true;

/// Default entry point — full flavor with AI enabled.
/// For flavor-specific builds, use main_full.dart or main_lite.dart.
Future<void> main() async => appMain(aiEnabled: true);

/// Shared app bootstrap — called from entry-point files.
Future<void> appMain({required bool aiEnabled}) async {
WidgetsFlutterBinding.ensureInitialized();
await FlutterGemma.initialize(maxDownloadRetries: 5);
kAiEnabled = aiEnabled;
runApp(const KoshikaApp());
}

Future<void> migrateEmbeddingsIfNeeded() {
if (!kAiEnabled || !embeddingService.isLoaded) {
return Future.value();
}

return _embeddingMigrationTask ??= _runEmbeddingMigration().whenComplete(() {
_embeddingMigrationTask = null;
});
}

Future<void> _runEmbeddingMigration() async {
try {
final allResults = objectbox.biomarkerResultBox.getAll();
if (allResults.isEmpty) return;

final needsMigration = allResults.any(
(result) =>
result.embedding == null ||
result.embedding!.isEmpty ||
result.embedding!.length != 384,
);
if (!needsMigration) return;

debugPrint(
'KoshikaApp: migrating ${allResults.length} embeddings to 384-dim',
);
await vectorStoreService.rebuildIndex(allResults);
} catch (e) {
debugPrint('Embedding migration failed (non-fatal): $e');
}
}

class KoshikaApp extends StatelessWidget {
const KoshikaApp({super.key});

Expand Down Expand Up @@ -123,19 +164,19 @@ class HomeScreen extends StatefulWidget {
class _HomeScreenState extends State<HomeScreen> {
int _currentIndex = 0;

/// Screens available depends on whether AI is enabled.
List<Widget> get _screens => kAiEnabled
? const [
DashboardScreen(),
ReportsScreen(),
ChatScreen(),
SettingsScreen(),
]
: const [DashboardScreen(), ReportsScreen(), SettingsScreen()];

Widget _buildCurrentScreen() {
switch (_currentIndex) {
case 0:
return const DashboardScreen();
case 1:
return const ReportsScreen();
case 2:
return const ChatScreen();
case 3:
return const SettingsScreen();
default:
return const DashboardScreen();
}
if (_currentIndex < _screens.length) return _screens[_currentIndex];
return const DashboardScreen();
}

@override
Expand Down Expand Up @@ -175,21 +216,23 @@ class _HomeScreenState extends State<HomeScreen> {
label: 'Reports',
onTap: () => setState(() => _currentIndex = 1),
),
if (kAiEnabled)
_NavItem(
index: 2,
currentIndex: _currentIndex,
icon: Icons.chat_outlined,
activeIcon: Icons.chat,
label: 'Chat',
onTap: () => setState(() => _currentIndex = 2),
),
_NavItem(
index: 2,
currentIndex: _currentIndex,
icon: Icons.chat_outlined,
activeIcon: Icons.chat,
label: 'Chat',
onTap: () => setState(() => _currentIndex = 2),
),
_NavItem(
index: 3,
index: kAiEnabled ? 3 : 2,
currentIndex: _currentIndex,
icon: Icons.settings_outlined,
activeIcon: Icons.settings,
label: 'Settings',
onTap: () => setState(() => _currentIndex = 3),
onTap: () =>
setState(() => _currentIndex = kAiEnabled ? 3 : 2),
),
],
),
Expand Down
4 changes: 4 additions & 0 deletions lib/main_full.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import 'main.dart';

/// Entry point for the full flavor — includes all AI features.
Future<void> main() async => appMain(aiEnabled: true);
4 changes: 4 additions & 0 deletions lib/main_lite.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import 'main.dart';

/// Entry point for the lite flavor — no AI, no model downloads.
Future<void> main() async => appMain(aiEnabled: false);
3 changes: 2 additions & 1 deletion lib/models/biomarker_result.dart
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,9 @@ class BiomarkerResult {
@Index()
DateTime testDate;

/// Embedding vector for RAG (stored as float list, used with HNSW later)
/// Embedding vector for RAG (384-dim from bge-small-en-v1.5).
@Property(type: PropertyType.floatVector)
@HnswIndex(dimensions: 384)
List<double>? embedding;

BiomarkerResult({
Expand Down
Loading
Loading