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.
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
```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;
SessionStatus status = SessionStatus.loading;
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 {
final minimumSplash = Future<void>.delayed(
@ -22,6 +30,10 @@ class SessionController extends ChangeNotifier {
} else {
try {
user = await api.me();
await _selectInitialOrganization();
if (organizationId == null) {
throw StateError('No approved organization membership');
}
status = SessionStatus.signedIn;
} catch (_) {
await tokens.clear();
@ -36,21 +48,46 @@ class SessionController extends ChangeNotifier {
final result = await api.login(username, password);
await tokens.write(result.token);
user = result.user;
await _selectInitialOrganization();
status = SessionStatus.signedIn;
notifyListeners();
}
Future<void> register(Map<String, dynamic> input) async {
final result = await api.register(input);
await tokens.write(result.token);
user = result.user;
status = SessionStatus.signedIn;
Future<String> register(Map<String, dynamic> input) => api.register(input);
Future<void> _selectInitialOrganization() async {
final approved = user?.approvedMemberships ?? const [];
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();
}
Future<void> logout() async {
await tokens.clear();
await tokens.clearOrganization();
user = null;
organizationId = null;
status = SessionStatus.signedOut;
notifyListeners();
}

View File

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

View File

@ -73,7 +73,9 @@ class WhatsDriveApp extends StatelessWidget {
builder: (context, session, _) => switch (session.status) {
SessionStatus.loading => const SplashScreen(),
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.gender,
required this.age,
this.memberships = const [],
});
final String id;
@ -16,6 +17,14 @@ class UserProfile {
final String email;
final String gender;
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(
id: json['id'] as String,
@ -25,5 +34,53 @@ class UserProfile {
email: json['email'] as String,
gender: json['gender'] as String,
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
void didChangeDependencies() {
super.didChangeDependencies();
context.read<UploadController>().selectOrganization(
context.read<SessionController>().organizationId,
);
final controller = context.read<UploadController>();
if (_uploadController == controller) return;
_uploadController?.removeListener(_onUploadChanged);
@ -250,7 +253,9 @@ class _DriveScreenState extends State<DriveScreen> {
@override
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(
canPop: _path.isEmpty,
onPopInvokedWithResult: (didPop, result) {
@ -265,11 +270,13 @@ class _DriveScreenState extends State<DriveScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_path.isEmpty ? 'Metatron-Drive' : _path.last.name,
_path.isEmpty
? membership?.organization.name ?? 'Metatron-Drive'
: _path.last.name,
style: const TextStyle(fontWeight: FontWeight.w800),
),
Text(
'Hello, ${user.name.split(' ').first}',
'Hello, ${user.name.split(' ').first}${membership?.organization.code ?? ''}',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.normal,
@ -279,6 +286,28 @@ class _DriveScreenState extends State<DriveScreen> {
],
),
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(
onPressed: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const UploadHistoryScreen()),
@ -412,6 +441,7 @@ class _DriveScreenState extends State<DriveScreen> {
file: _files[index],
token: _token,
api: context.read<ApiClient>(),
organizationId: session.organizationId,
onDelete: () => _deleteFile(_files[index]),
),
),
@ -457,6 +487,15 @@ class _DriveScreenState extends State<DriveScreen> {
style: const TextStyle(color: Colors.black54),
),
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(
onPressed: () {
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 {
final password = TextEditingController();
final confirmed = await showDialog<bool>(
@ -668,11 +757,13 @@ class _FileCard extends StatelessWidget {
required this.file,
required this.token,
required this.api,
required this.organizationId,
required this.onDelete,
});
final DriveFile file;
final String? token;
final ApiClient api;
final String? organizationId;
final VoidCallback onDelete;
@override
Widget build(BuildContext context) => Card(
@ -686,7 +777,10 @@ class _FileCard extends StatelessWidget {
child: file.isImage && token != null
? Image.network(
api.contentUrl(file.id),
headers: {'Authorization': 'Bearer $token'},
headers: {
'Authorization': 'Bearer $token',
..._organizationHeader(organizationId),
},
fit: BoxFit.cover,
errorBuilder: (_, _, _) =>
const Icon(Icons.broken_image_outlined, size: 42),
@ -741,6 +835,9 @@ class _FileCard extends StatelessWidget {
: '${(bytes / 1024 / 1024).toStringAsFixed(1)} MB';
}
Map<String, String> _organizationHeader(String? organizationId) =>
organizationId == null ? const {} : {'X-Organization-Id': organizationId};
class _UploadStrip extends StatelessWidget {
const _UploadStrip();
@override

View File

@ -12,6 +12,7 @@ class RegisterScreen extends StatefulWidget {
class _RegisterScreenState extends State<RegisterScreen> {
final _form = GlobalKey<FormState>();
final _name = TextEditingController();
final _organizationCode = TextEditingController();
final _username = TextEditingController();
final _email = TextEditingController();
final _phone = TextEditingController();
@ -20,7 +21,14 @@ class _RegisterScreenState extends State<RegisterScreen> {
@override
void dispose() {
for (final value in [_name, _username, _email, _phone, _password]) {
for (final value in [
_organizationCode,
_name,
_username,
_email,
_phone,
_password,
]) {
value.dispose();
}
super.dispose();
@ -30,7 +38,8 @@ class _RegisterScreenState extends State<RegisterScreen> {
if (!_form.currentState!.validate()) return;
setState(() => _busy = true);
try {
await context.read<SessionController>().register({
final message = await context.read<SessionController>().register({
'organizationCode': _organizationCode.text.trim().toUpperCase(),
'name': _name.text.trim(),
'username': _username.text.trim().toLowerCase(),
'email': _email.text.trim().toLowerCase(),
@ -41,7 +50,11 @@ class _RegisterScreenState extends State<RegisterScreen> {
'gender': 'Prefer not to say',
'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) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@ -83,6 +96,18 @@ class _RegisterScreenState extends State<RegisterScreen> {
'We create your personal Google Drive folder automatically.',
),
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(
controller: _name,
textCapitalization: TextCapitalization.words,

View File

@ -17,7 +17,13 @@ class ApiClient {
InterceptorsWrapper(
onRequest: (options, handler) async {
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);
},
),
@ -53,14 +59,9 @@ class ApiClient {
);
}
Future<({String token, UserProfile user})> register(
Map<String, dynamic> input,
) async {
Future<String> register(Map<String, dynamic> input) async {
final response = await dio.post('/auth/register', data: input);
return (
token: response.data['token'] as String,
user: UserProfile.fromJson(response.data['user']),
);
return response.data['message'] as String;
}
Future<UserProfile> me() async {
@ -71,6 +72,21 @@ class ApiClient {
Future<void> deleteAccount(String 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 {
final response = await dio.get(
'/folders',

View File

@ -2,10 +2,15 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class TokenStore {
static const _tokenKey = 'auth_token';
static const _organizationKey = 'organization_id';
const TokenStore();
FlutterSecureStorage get _storage => const FlutterSecureStorage();
Future<String?> read() => _storage.read(key: _tokenKey);
Future<void> write(String token) =>
_storage.write(key: _tokenKey, value: token);
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);
}