Compare commits

..

2 Commits

29 changed files with 317 additions and 281 deletions

View File

@ -2,6 +2,8 @@
Flutter mobile frontend for the private Metatron-Drive file workspace. Flutter mobile frontend for the private Metatron-Drive file workspace.
The app uses one global account with multiple organization memberships. New accounts apply with an organization code; organization administrators must approve access before login.
## Run ## Run
```bash ```bash

14
android/.gitignore vendored
View File

@ -1,14 +0,0 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks

View File

@ -1,44 +0,0 @@
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")
}
android {
namespace = "com.metatroncube.whats_drive_app"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.metatroncube.whats_drive_app"
// 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
}
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")
}
}
}
flutter {
source = "../.."
}

View File

@ -1,7 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@ -1,52 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<application
android:label="Metatron-Drive"
android:name="${applicationName}"
android:usesCleartextTraffic="true"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>

View File

@ -1,5 +0,0 @@
package com.metatroncube.whats_drive_app
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()

View File

@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/splash_background" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

View File

@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/splash_background" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -1,18 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="splash_background">#101D42</color>
</resources>

View File

@ -1,18 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@ -1,7 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@ -1,24 +0,0 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}

View File

@ -1,5 +0,0 @@
org.gradle.jvmargs=-Xmx2G -XX:MaxMetaspaceSize=1G -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError
org.gradle.workers.max=2
kotlin.daemon.jvmargs=-Xmx1G
kotlin.incremental=false
android.useAndroidX=true

View File

@ -1,5 +0,0 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip

View File

@ -1,26 +0,0 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
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.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
}
include(":app")

View File

@ -11,6 +11,14 @@ class SessionController extends ChangeNotifier {
final TokenStore tokens; final TokenStore tokens;
SessionStatus status = SessionStatus.loading; SessionStatus status = SessionStatus.loading;
UserProfile? user; UserProfile? user;
String? organizationId;
OrganizationMembership? get membership {
final matches = user?.approvedMemberships.where(
(item) => item.organization.id == organizationId,
);
return matches == null || matches.isEmpty ? null : matches.first;
}
Future<void> initialize() async { Future<void> initialize() async {
final minimumSplash = Future<void>.delayed( final minimumSplash = Future<void>.delayed(
@ -22,6 +30,10 @@ class SessionController extends ChangeNotifier {
} else { } else {
try { try {
user = await api.me(); user = await api.me();
await _selectInitialOrganization();
if (organizationId == null) {
throw StateError('No approved organization membership');
}
status = SessionStatus.signedIn; status = SessionStatus.signedIn;
} catch (_) { } catch (_) {
await tokens.clear(); await tokens.clear();
@ -36,21 +48,46 @@ class SessionController extends ChangeNotifier {
final result = await api.login(username, password); final result = await api.login(username, password);
await tokens.write(result.token); await tokens.write(result.token);
user = result.user; user = result.user;
await _selectInitialOrganization();
status = SessionStatus.signedIn; status = SessionStatus.signedIn;
notifyListeners(); notifyListeners();
} }
Future<void> register(Map<String, dynamic> input) async { Future<String> register(Map<String, dynamic> input) => api.register(input);
final result = await api.register(input);
await tokens.write(result.token); Future<void> _selectInitialOrganization() async {
user = result.user; final approved = user?.approvedMemberships ?? const [];
status = SessionStatus.signedIn; final saved = await tokens.readOrganization();
final selected = approved.where((item) => item.organization.id == saved);
organizationId = selected.isNotEmpty
? selected.first.organization.id
: (approved.isEmpty ? null : approved.first.organization.id);
if (organizationId != null) {
await tokens.writeOrganization(organizationId!);
}
}
Future<void> selectOrganization(String id) async {
if (!(user?.approvedMemberships.any((item) => item.organization.id == id) ??
false)) {
return;
}
organizationId = id;
await tokens.writeOrganization(id);
notifyListeners();
}
Future<void> refreshProfile() async {
user = await api.me();
await _selectInitialOrganization();
notifyListeners(); notifyListeners();
} }
Future<void> logout() async { Future<void> logout() async {
await tokens.clear(); await tokens.clear();
await tokens.clearOrganization();
user = null; user = null;
organizationId = null;
status = SessionStatus.signedOut; status = SessionStatus.signedOut;
notifyListeners(); notifyListeners();
} }

View File

@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:background_downloader/background_downloader.dart'; import 'package:background_downloader/background_downloader.dart';
@ -17,6 +18,7 @@ class UploadRecord {
this.status = TaskStatus.enqueued, this.status = TaskStatus.enqueued,
this.sizeBytes = -1, this.sizeBytes = -1,
this.errorMessage, this.errorMessage,
this.organizationId,
}); });
final String taskId; final String taskId;
@ -27,6 +29,7 @@ class UploadRecord {
final TaskStatus status; final TaskStatus status;
final int sizeBytes; final int sizeBytes;
final String? errorMessage; final String? errorMessage;
final String? organizationId;
bool get isFinished => status.isFinalState; bool get isFinished => status.isFinalState;
bool get canRetry => status == TaskStatus.failed; bool get canRetry => status == TaskStatus.failed;
@ -45,6 +48,7 @@ class UploadRecord {
status: status ?? this.status, status: status ?? this.status,
sizeBytes: sizeBytes ?? this.sizeBytes, sizeBytes: sizeBytes ?? this.sizeBytes,
errorMessage: errorMessage ?? this.errorMessage, errorMessage: errorMessage ?? this.errorMessage,
organizationId: organizationId,
); );
} }
@ -64,15 +68,28 @@ class UploadController extends ChangeNotifier {
final Map<String, Task> _tasks = {}; final Map<String, Task> _tasks = {};
bool _notificationPermissionChecked = false; bool _notificationPermissionChecked = false;
bool _disposed = false; bool _disposed = false;
String? _organizationId;
List<UploadRecord> get uploads { List<UploadRecord> get uploads {
final result = _uploads.values.toList() final result =
..sort((a, b) => b.createdAt.compareTo(a.createdAt)); _uploads.values
.where(
(item) =>
item.organizationId == null ||
item.organizationId == _organizationId,
)
.toList()
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
return result; return result;
} }
int completedVersion = 0; int completedVersion = 0;
void selectOrganization(String? organizationId) {
if (_organizationId == organizationId) return;
_organizationId = organizationId;
}
Future<void> _restoreHistory() async { Future<void> _restoreHistory() async {
// start() initializes tracking asynchronously during application startup. // start() initializes tracking asynchronously during application startup.
// A short retry also covers slower devices on their first launch. // A short retry also covers slower devices on their first launch.
@ -102,6 +119,8 @@ class UploadController extends ChangeNotifier {
await _ensureNotificationPermission(); await _ensureNotificationPermission();
final token = await _tokens.read(); final token = await _tokens.read();
if (token == null) throw StateError('Your session has expired'); if (token == null) throw StateError('Your session has expired');
final organizationId = await _tokens.readOrganization();
if (organizationId == null) throw StateError('Select an organization');
final task = UploadTask( final task = UploadTask(
url: '${AppConfig.apiBaseUrl}/files/upload', url: '${AppConfig.apiBaseUrl}/files/upload',
filename: filename, filename: filename,
@ -109,12 +128,18 @@ class UploadController extends ChangeNotifier {
baseDirectory: BaseDirectory.applicationSupport, baseDirectory: BaseDirectory.applicationSupport,
fileField: 'file', fileField: 'file',
fields: folderId == null ? const {} : {'folderId': folderId}, fields: folderId == null ? const {} : {'folderId': folderId},
headers: {'Authorization': 'Bearer $token'}, headers: {
'Authorization': 'Bearer $token',
'X-Organization-Id': organizationId,
},
group: 'uploads', group: 'uploads',
updates: Updates.statusAndProgress, updates: Updates.statusAndProgress,
retries: 2, retries: 2,
displayName: displayName, displayName: displayName,
metaData: displayName, metaData: jsonEncode({
'name': displayName,
'organizationId': organizationId,
}),
); );
_tasks[task.taskId] = task; _tasks[task.taskId] = task;
_uploads[task.taskId] = UploadRecord( _uploads[task.taskId] = UploadRecord(
@ -122,6 +147,7 @@ class UploadController extends ChangeNotifier {
name: displayName, name: displayName,
filename: filename, filename: filename,
createdAt: task.creationTime, createdAt: task.creationTime,
organizationId: organizationId,
); );
notifyListeners(); notifyListeners();
final accepted = await FileDownloader().enqueue(task); final accepted = await FileDownloader().enqueue(task);
@ -180,23 +206,40 @@ class UploadController extends ChangeNotifier {
_tasks[record.taskId] = record.task; _tasks[record.taskId] = record.task;
_uploads[record.taskId] = UploadRecord( _uploads[record.taskId] = UploadRecord(
taskId: record.taskId, taskId: record.taskId,
name: record.task.displayName, name: _taskName(record.task),
filename: record.task.filename, filename: record.task.filename,
createdAt: record.task.creationTime, createdAt: record.task.creationTime,
progress: record.progress.clamp(0, 1), progress: record.progress.clamp(0, 1),
status: record.status, status: record.status,
sizeBytes: record.expectedFileSize, sizeBytes: record.expectedFileSize,
errorMessage: record.exception?.description, errorMessage: record.exception?.description,
organizationId: _taskOrganization(record.task),
); );
} }
UploadRecord _fromTask(Task task) => UploadRecord( UploadRecord _fromTask(Task task) => UploadRecord(
taskId: task.taskId, taskId: task.taskId,
name: task.displayName, name: _taskName(task),
filename: task.filename, filename: task.filename,
createdAt: task.creationTime, createdAt: task.creationTime,
organizationId: _taskOrganization(task),
); );
Map<String, dynamic>? _taskMetadata(Task task) {
try {
final value = jsonDecode(task.metaData);
return value is Map<String, dynamic> ? value : null;
} catch (_) {
return null;
}
}
String _taskName(Task task) =>
_taskMetadata(task)?['name'] as String? ?? task.displayName;
String? _taskOrganization(Task task) =>
_taskMetadata(task)?['organizationId'] as String?;
Future<void> _deleteStagedFile(Task task) async { Future<void> _deleteStagedFile(Task task) async {
final file = File(await task.filePath()); final file = File(await task.filePath());
if (await file.exists()) await file.delete(); if (await file.exists()) await file.delete();
@ -218,9 +261,14 @@ class UploadController extends ChangeNotifier {
} }
final token = await _tokens.read(); final token = await _tokens.read();
if (token == null) throw StateError('Your session has expired'); if (token == null) throw StateError('Your session has expired');
final organizationId = await _tokens.readOrganization();
if (organizationId == null) throw StateError('Select an organization');
final retried = original.copyWith( final retried = original.copyWith(
taskId: DateTime.now().microsecondsSinceEpoch.toString(), taskId: DateTime.now().microsecondsSinceEpoch.toString(),
headers: {'Authorization': 'Bearer $token'}, headers: {
'Authorization': 'Bearer $token',
'X-Organization-Id': organizationId,
},
creationTime: DateTime.now(), creationTime: DateTime.now(),
retriesRemaining: original.retries, retriesRemaining: original.retries,
); );

View File

@ -73,7 +73,9 @@ class WhatsDriveApp extends StatelessWidget {
builder: (context, session, _) => switch (session.status) { builder: (context, session, _) => switch (session.status) {
SessionStatus.loading => const SplashScreen(), SessionStatus.loading => const SplashScreen(),
SessionStatus.signedOut => const LoginScreen(), SessionStatus.signedOut => const LoginScreen(),
SessionStatus.signedIn => const DriveScreen(), SessionStatus.signedIn => DriveScreen(
key: ValueKey(session.organizationId),
),
}, },
), ),
), ),

View File

@ -7,6 +7,7 @@ class UserProfile {
required this.email, required this.email,
required this.gender, required this.gender,
required this.age, required this.age,
this.memberships = const [],
}); });
final String id; final String id;
@ -16,6 +17,14 @@ class UserProfile {
final String email; final String email;
final String gender; final String gender;
final int age; final int age;
final List<OrganizationMembership> memberships;
List<OrganizationMembership> get approvedMemberships => memberships
.where(
(item) =>
item.status == 'APPROVED' && item.organization.status == 'ACTIVE',
)
.toList();
factory UserProfile.fromJson(Map<String, dynamic> json) => UserProfile( factory UserProfile.fromJson(Map<String, dynamic> json) => UserProfile(
id: json['id'] as String, id: json['id'] as String,
@ -25,5 +34,53 @@ class UserProfile {
email: json['email'] as String, email: json['email'] as String,
gender: json['gender'] as String, gender: json['gender'] as String,
age: json['age'] as int, age: json['age'] as int,
memberships: (json['memberships'] as List? ?? const [])
.map((item) => OrganizationMembership.fromJson(item))
.toList(),
); );
} }
class OrganizationSummary {
const OrganizationSummary({
required this.id,
required this.code,
required this.name,
required this.status,
});
final String id;
final String code;
final String name;
final String status;
factory OrganizationSummary.fromJson(Map<String, dynamic> json) =>
OrganizationSummary(
id: json['id'] as String,
code: json['code'] as String,
name: json['name'] as String,
status: json['status'] as String,
);
}
class OrganizationMembership {
const OrganizationMembership({
required this.id,
required this.role,
required this.status,
required this.organization,
this.rejectionReason,
});
final String id;
final String role;
final String status;
final String? rejectionReason;
final OrganizationSummary organization;
factory OrganizationMembership.fromJson(Map<String, dynamic> json) =>
OrganizationMembership(
id: json['id'] as String,
role: json['role'] as String,
status: json['status'] as String,
rejectionReason: json['rejectionReason'] as String?,
organization: OrganizationSummary.fromJson(json['organization']),
);
}

View File

@ -44,6 +44,9 @@ class _DriveScreenState extends State<DriveScreen> {
@override @override
void didChangeDependencies() { void didChangeDependencies() {
super.didChangeDependencies(); super.didChangeDependencies();
context.read<UploadController>().selectOrganization(
context.read<SessionController>().organizationId,
);
final controller = context.read<UploadController>(); final controller = context.read<UploadController>();
if (_uploadController == controller) return; if (_uploadController == controller) return;
_uploadController?.removeListener(_onUploadChanged); _uploadController?.removeListener(_onUploadChanged);
@ -250,7 +253,9 @@ class _DriveScreenState extends State<DriveScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final user = context.watch<SessionController>().user!; final session = context.watch<SessionController>();
final user = session.user!;
final membership = session.membership;
return PopScope( return PopScope(
canPop: _path.isEmpty, canPop: _path.isEmpty,
onPopInvokedWithResult: (didPop, result) { onPopInvokedWithResult: (didPop, result) {
@ -265,11 +270,13 @@ class _DriveScreenState extends State<DriveScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
_path.isEmpty ? 'Metatron-Drive' : _path.last.name, _path.isEmpty
? membership?.organization.name ?? 'Metatron-Drive'
: _path.last.name,
style: const TextStyle(fontWeight: FontWeight.w800), style: const TextStyle(fontWeight: FontWeight.w800),
), ),
Text( Text(
'Hello, ${user.name.split(' ').first}', 'Hello, ${user.name.split(' ').first}${membership?.organization.code ?? ''}',
style: const TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.normal, fontWeight: FontWeight.normal,
@ -279,6 +286,28 @@ class _DriveScreenState extends State<DriveScreen> {
], ],
), ),
actions: [ actions: [
PopupMenuButton<String>(
tooltip: 'Switch organization',
icon: const Icon(Icons.business_outlined),
onSelected: session.selectOrganization,
itemBuilder: (_) => user.approvedMemberships
.map(
(item) => PopupMenuItem(
value: item.organization.id,
child: Row(
children: [
if (item.organization.id == session.organizationId)
const Padding(
padding: EdgeInsets.only(right: 8),
child: Icon(Icons.check, size: 18),
),
Text(item.organization.name),
],
),
),
)
.toList(),
),
IconButton( IconButton(
onPressed: () => Navigator.of(context).push( onPressed: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const UploadHistoryScreen()), MaterialPageRoute(builder: (_) => const UploadHistoryScreen()),
@ -412,6 +441,7 @@ class _DriveScreenState extends State<DriveScreen> {
file: _files[index], file: _files[index],
token: _token, token: _token,
api: context.read<ApiClient>(), api: context.read<ApiClient>(),
organizationId: session.organizationId,
onDelete: () => _deleteFile(_files[index]), onDelete: () => _deleteFile(_files[index]),
), ),
), ),
@ -457,6 +487,15 @@ class _DriveScreenState extends State<DriveScreen> {
style: const TextStyle(color: Colors.black54), style: const TextStyle(color: Colors.black54),
), ),
const SizedBox(height: 22), const SizedBox(height: 22),
OutlinedButton.icon(
onPressed: () {
Navigator.pop(sheetContext);
_joinOrganization();
},
icon: const Icon(Icons.group_add_outlined),
label: const Text('Join another organization'),
),
const SizedBox(height: 8),
OutlinedButton.icon( OutlinedButton.icon(
onPressed: () { onPressed: () {
Navigator.pop(sheetContext); Navigator.pop(sheetContext);
@ -481,6 +520,56 @@ class _DriveScreenState extends State<DriveScreen> {
); );
} }
Future<void> _joinOrganization() async {
final code = TextEditingController();
final submitted = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Join an organization'),
content: TextField(
controller: code,
textCapitalization: TextCapitalization.characters,
decoration: const InputDecoration(labelText: 'Organization code'),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('Submit request'),
),
],
),
);
if (submitted != true || !mounted) {
code.dispose();
return;
}
try {
final message = await context.read<ApiClient>().joinOrganization(
code.text.trim().toUpperCase(),
);
if (mounted) {
await context.read<SessionController>().refreshProfile();
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
}
} catch (error) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.read<ApiClient>().messageFrom(error))),
);
}
} finally {
code.dispose();
}
}
Future<void> _confirmAccountDeletion() async { Future<void> _confirmAccountDeletion() async {
final password = TextEditingController(); final password = TextEditingController();
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
@ -668,11 +757,13 @@ class _FileCard extends StatelessWidget {
required this.file, required this.file,
required this.token, required this.token,
required this.api, required this.api,
required this.organizationId,
required this.onDelete, required this.onDelete,
}); });
final DriveFile file; final DriveFile file;
final String? token; final String? token;
final ApiClient api; final ApiClient api;
final String? organizationId;
final VoidCallback onDelete; final VoidCallback onDelete;
@override @override
Widget build(BuildContext context) => Card( Widget build(BuildContext context) => Card(
@ -686,7 +777,10 @@ class _FileCard extends StatelessWidget {
child: file.isImage && token != null child: file.isImage && token != null
? Image.network( ? Image.network(
api.contentUrl(file.id), api.contentUrl(file.id),
headers: {'Authorization': 'Bearer $token'}, headers: {
'Authorization': 'Bearer $token',
..._organizationHeader(organizationId),
},
fit: BoxFit.cover, fit: BoxFit.cover,
errorBuilder: (_, _, _) => errorBuilder: (_, _, _) =>
const Icon(Icons.broken_image_outlined, size: 42), const Icon(Icons.broken_image_outlined, size: 42),
@ -741,6 +835,9 @@ class _FileCard extends StatelessWidget {
: '${(bytes / 1024 / 1024).toStringAsFixed(1)} MB'; : '${(bytes / 1024 / 1024).toStringAsFixed(1)} MB';
} }
Map<String, String> _organizationHeader(String? organizationId) =>
organizationId == null ? const {} : {'X-Organization-Id': organizationId};
class _UploadStrip extends StatelessWidget { class _UploadStrip extends StatelessWidget {
const _UploadStrip(); const _UploadStrip();
@override @override

View File

@ -12,6 +12,7 @@ class RegisterScreen extends StatefulWidget {
class _RegisterScreenState extends State<RegisterScreen> { class _RegisterScreenState extends State<RegisterScreen> {
final _form = GlobalKey<FormState>(); final _form = GlobalKey<FormState>();
final _name = TextEditingController(); final _name = TextEditingController();
final _organizationCode = TextEditingController();
final _username = TextEditingController(); final _username = TextEditingController();
final _email = TextEditingController(); final _email = TextEditingController();
final _phone = TextEditingController(); final _phone = TextEditingController();
@ -20,7 +21,14 @@ class _RegisterScreenState extends State<RegisterScreen> {
@override @override
void dispose() { void dispose() {
for (final value in [_name, _username, _email, _phone, _password]) { for (final value in [
_organizationCode,
_name,
_username,
_email,
_phone,
_password,
]) {
value.dispose(); value.dispose();
} }
super.dispose(); super.dispose();
@ -30,7 +38,8 @@ class _RegisterScreenState extends State<RegisterScreen> {
if (!_form.currentState!.validate()) return; if (!_form.currentState!.validate()) return;
setState(() => _busy = true); setState(() => _busy = true);
try { try {
await context.read<SessionController>().register({ final message = await context.read<SessionController>().register({
'organizationCode': _organizationCode.text.trim().toUpperCase(),
'name': _name.text.trim(), 'name': _name.text.trim(),
'username': _username.text.trim().toLowerCase(), 'username': _username.text.trim().toLowerCase(),
'email': _email.text.trim().toLowerCase(), 'email': _email.text.trim().toLowerCase(),
@ -41,7 +50,11 @@ class _RegisterScreenState extends State<RegisterScreen> {
'gender': 'Prefer not to say', 'gender': 'Prefer not to say',
'password': _password.text, 'password': _password.text,
}); });
if (mounted) Navigator.of(context).popUntil((route) => route.isFirst); if (mounted) {
final messenger = ScaffoldMessenger.of(context);
Navigator.of(context).popUntil((route) => route.isFirst);
messenger.showSnackBar(SnackBar(content: Text(message)));
}
} catch (error) { } catch (error) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@ -83,6 +96,18 @@ class _RegisterScreenState extends State<RegisterScreen> {
'We create your personal Google Drive folder automatically.', 'We create your personal Google Drive folder automatically.',
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
TextFormField(
controller: _organizationCode,
textCapitalization: TextCapitalization.characters,
decoration: const InputDecoration(
labelText: 'Organization code',
prefixIcon: Icon(Icons.business_outlined),
),
validator: (value) => (value?.trim().length ?? 0) < 4
? 'Enter your organization code'
: null,
),
const SizedBox(height: 12),
TextFormField( TextFormField(
controller: _name, controller: _name,
textCapitalization: TextCapitalization.words, textCapitalization: TextCapitalization.words,

View File

@ -17,7 +17,13 @@ class ApiClient {
InterceptorsWrapper( InterceptorsWrapper(
onRequest: (options, handler) async { onRequest: (options, handler) async {
final token = await _tokens.read(); final token = await _tokens.read();
if (token != null) options.headers['Authorization'] = 'Bearer $token'; if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
final organizationId = await _tokens.readOrganization();
if (organizationId != null) {
options.headers['X-Organization-Id'] = organizationId;
}
handler.next(options); handler.next(options);
}, },
), ),
@ -53,14 +59,9 @@ class ApiClient {
); );
} }
Future<({String token, UserProfile user})> register( Future<String> register(Map<String, dynamic> input) async {
Map<String, dynamic> input,
) async {
final response = await dio.post('/auth/register', data: input); final response = await dio.post('/auth/register', data: input);
return ( return response.data['message'] as String;
token: response.data['token'] as String,
user: UserProfile.fromJson(response.data['user']),
);
} }
Future<UserProfile> me() async { Future<UserProfile> me() async {
@ -71,6 +72,21 @@ class ApiClient {
Future<void> deleteAccount(String password) => Future<void> deleteAccount(String password) =>
dio.delete('/retention/account', data: {'password': password}); dio.delete('/retention/account', data: {'password': password});
Future<OrganizationSummary> lookupOrganization(String code) async {
final response = await dio.get(
'/organizations/lookup/${Uri.encodeComponent(code)}',
);
return OrganizationSummary.fromJson(response.data['organization']);
}
Future<String> joinOrganization(String code) async {
final response = await dio.post(
'/auth/join',
data: {'organizationCode': code},
);
return response.data['message'] as String;
}
Future<List<DriveFolder>> folders(String? parentId) async { Future<List<DriveFolder>> folders(String? parentId) async {
final response = await dio.get( final response = await dio.get(
'/folders', '/folders',

View File

@ -2,10 +2,15 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class TokenStore { class TokenStore {
static const _tokenKey = 'auth_token'; static const _tokenKey = 'auth_token';
static const _organizationKey = 'organization_id';
const TokenStore(); const TokenStore();
FlutterSecureStorage get _storage => const FlutterSecureStorage(); FlutterSecureStorage get _storage => const FlutterSecureStorage();
Future<String?> read() => _storage.read(key: _tokenKey); Future<String?> read() => _storage.read(key: _tokenKey);
Future<void> write(String token) => Future<void> write(String token) =>
_storage.write(key: _tokenKey, value: token); _storage.write(key: _tokenKey, value: token);
Future<void> clear() => _storage.delete(key: _tokenKey); Future<void> clear() => _storage.delete(key: _tokenKey);
Future<String?> readOrganization() => _storage.read(key: _organizationKey);
Future<void> writeOrganization(String id) =>
_storage.write(key: _organizationKey, value: id);
Future<void> clearOrganization() => _storage.delete(key: _organizationKey);
} }