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
32 changes: 25 additions & 7 deletions app/android/app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
import java.util.Properties
import java.io.FileInputStream

plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}

val keystoreProperties = Properties()
val keystorePropertiesFile = rootProject.file("key.properties")
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
}

android {
namespace = "com.humanloop.humanloop"
compileSdk = flutter.compileSdkVersion
Expand All @@ -20,21 +28,31 @@ android {
}

defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.humanloop.humanloop"
// 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
versionName = flutter.versionName
}

signingConfigs {
if (keystorePropertiesFile.exists()) {
create("release") {
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
storeFile = file(keystoreProperties["storeFile"] as String)
storePassword = keystoreProperties["storePassword"] as String
}
}
}

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")
signingConfig = if (keystorePropertiesFile.exists()) {
signingConfigs.getByName("release")
} else {
signingConfigs.getByName("debug")
}
}
}
}
Expand Down
5 changes: 3 additions & 2 deletions app/android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-feature android:name="android.hardware.camera" android:required="true"/>
<application
android:label="humanloop"
android:label="HumanLoop"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="false">
<activity
android:name=".MainActivity"
android:exported="true"
Expand Down
4 changes: 4 additions & 0 deletions app/android/key.properties.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
storePassword=CHANGE_ME
keyPassword=CHANGE_ME
keyAlias=upload
storeFile=/absolute/path/to/upload-keystore.jks
56 changes: 56 additions & 0 deletions app/ios/Runner/PrivacyInfo.xcprivacy
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?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>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array>
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypePreciseLocation</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<true/>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array>
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
</array>
</dict>
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypeOtherUserContent</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<true/>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array>
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
</array>
</dict>
</array>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>C617.1</string>
</array>
</dict>
</array>
</dict>
</plist>
18 changes: 18 additions & 0 deletions app/lib/config.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
class AppConfig {
static const apiBaseUrl = String.fromEnvironment(
'API_URL',
defaultValue: 'https://api.humanloop.app',
);

static const privacyPolicyUrl = String.fromEnvironment(
'PRIVACY_URL',
defaultValue: 'https://humanloop.app/privacy',
);

static const termsUrl = String.fromEnvironment(
'TERMS_URL',
defaultValue: 'https://humanloop.app/terms',
);

static const supportEmail = 'support@humanloop.app';
}
30 changes: 29 additions & 1 deletion app/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ class HumanLoopApp extends ConsumerWidget {
final hasOnboarded = prefs.getBool('onboarded') ?? false;

Widget home;
if (auth.isSignedIn) {
if (auth.loading) {
home = const _SplashScreen();
} else if (auth.isSignedIn) {
home = const RootNav();
} else if (hasOnboarded) {
home = const LoginScreen();
Expand All @@ -64,6 +66,32 @@ class HumanLoopApp extends ConsumerWidget {
}
}

class _SplashScreen extends StatelessWidget {
const _SplashScreen();

@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.background,
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(color: AppColors.primary, borderRadius: BorderRadius.circular(24)),
child: const Icon(Icons.precision_manufacturing_rounded, color: Colors.white, size: 44),
),
const SizedBox(height: 32),
const CircularProgressIndicator(color: AppColors.primary),
],
),
),
);
}
}

class RootNav extends StatefulWidget {
const RootNav({super.key});

Expand Down
40 changes: 17 additions & 23 deletions app/lib/providers/auth_provider.dart
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../main.dart';
import '../models/user.dart';
import '../services/api_service.dart';
import '../services/auth_service.dart';
import '../services/token_store.dart';

class AuthState {
final AppUser? user;
Expand All @@ -27,14 +27,18 @@ class AuthState {
class AuthNotifier extends Notifier<AuthState> {
@override
AuthState build() {
final prefs = ref.read(prefsProvider);
final accessToken = prefs.getString('auth_token');
final storedRefresh = prefs.getString('refresh_token');
if (accessToken != null) {
Future.microtask(() => _restoreFromToken(accessToken, storedRefresh));
return AuthState(token: accessToken, refreshToken: storedRefresh);
Future.microtask(_init);
return const AuthState(loading: true);
}

Future<void> _init() async {
final accessToken = await TokenStore.readAccess();
final storedRefresh = await TokenStore.readRefresh();
if (accessToken == null) {
state = const AuthState();
return;
}
return const AuthState();
await _restoreFromToken(accessToken, storedRefresh);
}

Future<void> _restoreFromToken(String token, String? refreshToken) async {
Expand All @@ -50,27 +54,21 @@ class AuthNotifier extends Notifier<AuthState> {
if (tokens != null) {
final profile = await ApiService.getProfile(token: tokens.accessToken);
if (profile.isNotEmpty) {
final prefs = ref.read(prefsProvider);
await prefs.setString('auth_token', tokens.accessToken);
await prefs.setString('refresh_token', tokens.refreshToken);
await TokenStore.save(tokens.accessToken, tokens.refreshToken);
state = AuthState(user: AppUser.fromJson(profile), token: tokens.accessToken, refreshToken: tokens.refreshToken);
return;
}
}
}
final prefs = ref.read(prefsProvider);
await prefs.remove('auth_token');
await prefs.remove('refresh_token');
await TokenStore.clear();
state = const AuthState();
}

Future<void> signInWithGoogle() async {
state = state.copyWith(loading: true);
try {
final result = await AuthService.signInWithGoogle();
final prefs = ref.read(prefsProvider);
await prefs.setString('auth_token', result.tokens.accessToken);
await prefs.setString('refresh_token', result.tokens.refreshToken);
await TokenStore.save(result.tokens.accessToken, result.tokens.refreshToken);
state = AuthState(user: result.user, token: result.tokens.accessToken, refreshToken: result.tokens.refreshToken);
} catch (e) {
state = state.copyWith(loading: false);
Expand All @@ -82,9 +80,7 @@ class AuthNotifier extends Notifier<AuthState> {
state = state.copyWith(loading: true);
try {
final result = await AuthService.signInWithApple();
final prefs = ref.read(prefsProvider);
await prefs.setString('auth_token', result.tokens.accessToken);
await prefs.setString('refresh_token', result.tokens.refreshToken);
await TokenStore.save(result.tokens.accessToken, result.tokens.refreshToken);
state = AuthState(user: result.user, token: result.tokens.accessToken, refreshToken: result.tokens.refreshToken);
} catch (e) {
state = state.copyWith(loading: false);
Expand All @@ -104,9 +100,7 @@ class AuthNotifier extends Notifier<AuthState> {
if (token != null && refresh != null) {
await ApiService.revokeToken(token, refresh);
}
final prefs = ref.read(prefsProvider);
await prefs.remove('auth_token');
await prefs.remove('refresh_token');
await TokenStore.clear();
state = const AuthState();
}
}
Expand Down
58 changes: 53 additions & 5 deletions app/lib/screens/login_screen.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:url_launcher/url_launcher.dart';
import '../config.dart';
import '../theme/colors.dart';
import '../theme/text_styles.dart';
import '../providers/auth_provider.dart';
Expand Down Expand Up @@ -77,11 +80,7 @@ class LoginScreen extends ConsumerWidget {
),
],
const SizedBox(height: 32),
Text(
'By continuing, you agree to our Terms of Service\nand Privacy Policy.',
style: AppTextStyles.caption.copyWith(height: 1.5),
textAlign: TextAlign.center,
),
const _LegalText(),
if (kDebugMode) ...[
const SizedBox(height: 16),
TextButton(
Expand All @@ -105,6 +104,55 @@ class LoginScreen extends ConsumerWidget {
}
}

class _LegalText extends StatefulWidget {
const _LegalText();

@override
State<_LegalText> createState() => _LegalTextState();
}

class _LegalTextState extends State<_LegalText> {
late final TapGestureRecognizer _terms;
late final TapGestureRecognizer _privacy;

@override
void initState() {
super.initState();
_terms = TapGestureRecognizer()..onTap = () => _open(AppConfig.termsUrl);
_privacy = TapGestureRecognizer()..onTap = () => _open(AppConfig.privacyPolicyUrl);
}

Future<void> _open(String url) async {
await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
}

@override
void dispose() {
_terms.dispose();
_privacy.dispose();
super.dispose();
}

@override
Widget build(BuildContext context) {
final base = AppTextStyles.caption.copyWith(height: 1.5);
final link = base.copyWith(color: AppColors.primary, fontWeight: FontWeight.w600);
return Text.rich(
TextSpan(
style: base,
children: [
const TextSpan(text: 'By continuing, you agree to our '),
TextSpan(text: 'Terms of Service', style: link, recognizer: _terms),
const TextSpan(text: ' and '),
TextSpan(text: 'Privacy Policy', style: link, recognizer: _privacy),
const TextSpan(text: '.'),
],
),
textAlign: TextAlign.center,
);
}
}

class _SocialButton extends StatelessWidget {
final VoidCallback onTap;
final Widget icon;
Expand Down
Loading
Loading