start adding login flow

This commit is contained in:
Henry Hiles 2025-11-16 16:24:13 -05:00
commit c76a8f3c28
No known key found for this signature in database
14 changed files with 634 additions and 58 deletions

View file

@ -1,4 +1,3 @@
import "dart:io";
import "package:flutter/foundation.dart";
import "package:matrix/matrix.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
@ -13,6 +12,7 @@ class ClientController extends AsyncNotifier<Client> {
"nexus",
logLevel: kReleaseMode ? Level.warning : Level.verbose,
importantStateEvents: {"im.ponies.room_emotes"},
supportedLoginTypes: {AuthenticationTypes.password},
database: await MatrixSdkDatabase.init(
"nexus",
database: await databaseFactoryFfi.openDatabase(
@ -22,18 +22,18 @@ class ClientController extends AsyncNotifier<Client> {
);
// TODO: Save info
if (client.homeserver == null) {
await client.checkHomeserver(Uri.https("federated.nexus"));
}
if (client.accessToken == null) {
await client.login(
LoginType.mLoginPassword,
initialDeviceDisplayName: "Nexus Client",
deviceId: "temp", // TODO
identifier: AuthenticationUserIdentifier(user: "quadradical"),
password: File("./password.txt").readAsStringSync(),
);
}
// if (client.homeserver == null) {
// await client.checkHomeserver(Uri.https("federated.nexus"));
// }
// if (client.accessToken == null) {
// await client.login(
// LoginType.mLoginPassword,
// initialDeviceDisplayName: "Nexus Client",
// deviceId: "temp", // TODO
// identifier: AuthenticationUserIdentifier(user: "quadradical"),
// password: File("./password.txt").readAsStringSync(),
// );
// }
return client;
}

View file

@ -123,9 +123,13 @@ extension ToMessage on Event {
extension ToTheme on ColorScheme {
ThemeData get theme => ThemeData.from(colorScheme: this).copyWith(
cardTheme: CardThemeData(color: primaryContainer),
appBarTheme: AppBarTheme(
titleSpacing: 0,
backgroundColor: surfaceContainerLow,
),
inputDecorationTheme: const InputDecorationTheme(
border: OutlineInputBorder(),
),
);
}

View file

@ -0,0 +1,19 @@
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client_controller.dart";
class HomeserverHelper {
final Ref ref;
HomeserverHelper(this.ref);
Future<bool> setHomeserver(Uri homeserverUrl) async {
final client = await ref.watch(ClientController.provider.future);
try {
await client.checkHomeserver(homeserverUrl);
return true;
} catch (_) {
return false;
}
}
static final provider = Provider<HomeserverHelper>(HomeserverHelper.new);
}

View file

@ -1,7 +1,8 @@
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client_controller.dart";
import "package:nexus/helpers/extension_helper.dart";
import "package:nexus/widgets/room_chat.dart";
import "package:nexus/widgets/sidebar.dart";
import "package:nexus/pages/home_page.dart";
import "package:nexus/pages/homeserver_page.dart";
import "package:scaled_app/scaled_app.dart";
import "package:window_manager/window_manager.dart";
import "package:flutter/material.dart";
@ -21,46 +22,28 @@ void main() async {
runApp(ProviderScope(child: const App()));
}
class App extends StatelessWidget {
class App extends ConsumerWidget {
const App({super.key});
@override
Widget build(BuildContext context) => DynamicColorBuilder(
builder: (lightDynamic, darkDynamic) => LayoutBuilder(
builder: (context, constraints) {
final isDesktop = constraints.maxWidth > 650;
final showMembersByDefault = constraints.maxWidth > 1000;
return MaterialApp(
debugShowCheckedModeBanner: false,
theme:
(lightDynamic ?? ColorScheme.fromSeed(seedColor: Colors.indigo))
.theme,
darkTheme:
(darkDynamic ??
ColorScheme.fromSeed(
seedColor: Colors.indigo,
brightness: Brightness.dark,
))
.theme,
home: Scaffold(
body: Builder(
builder: (context) => Row(
children: [
if (isDesktop) Sidebar(),
Expanded(
child: RoomChat(
isDesktop: isDesktop,
showMembersByDefault: showMembersByDefault,
),
),
],
),
),
drawer: isDesktop ? null : Sidebar(),
Widget build(BuildContext context, WidgetRef ref) => DynamicColorBuilder(
builder: (lightDynamic, darkDynamic) => MaterialApp(
debugShowCheckedModeBanner: false,
theme: (lightDynamic ?? ColorScheme.fromSeed(seedColor: Colors.indigo))
.theme,
darkTheme:
(darkDynamic ??
ColorScheme.fromSeed(
seedColor: Colors.indigo,
brightness: Brightness.dark,
))
.theme,
home: ref
.watch(ClientController.provider)
.betterWhen(
data: (client) =>
client.accessToken == null ? HomeserverPage() : HomePage(),
),
);
},
),
);
}

View file

@ -0,0 +1,12 @@
import "package:freezed_annotation/freezed_annotation.dart";
part "homeserver.freezed.dart";
@freezed
abstract class Homeserver with _$Homeserver {
const factory Homeserver({
required String name,
required String description,
required Uri url,
required String iconUrl,
}) = _Homeserver;
}

View file

@ -0,0 +1,280 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'homeserver.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$Homeserver {
String get name; String get description; Uri get url; String get iconUrl;
/// Create a copy of Homeserver
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$HomeserverCopyWith<Homeserver> get copyWith => _$HomeserverCopyWithImpl<Homeserver>(this as Homeserver, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is Homeserver&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&(identical(other.url, url) || other.url == url)&&(identical(other.iconUrl, iconUrl) || other.iconUrl == iconUrl));
}
@override
int get hashCode => Object.hash(runtimeType,name,description,url,iconUrl);
@override
String toString() {
return 'Homeserver(name: $name, description: $description, url: $url, iconUrl: $iconUrl)';
}
}
/// @nodoc
abstract mixin class $HomeserverCopyWith<$Res> {
factory $HomeserverCopyWith(Homeserver value, $Res Function(Homeserver) _then) = _$HomeserverCopyWithImpl;
@useResult
$Res call({
String name, String description, Uri url, String iconUrl
});
}
/// @nodoc
class _$HomeserverCopyWithImpl<$Res>
implements $HomeserverCopyWith<$Res> {
_$HomeserverCopyWithImpl(this._self, this._then);
final Homeserver _self;
final $Res Function(Homeserver) _then;
/// Create a copy of Homeserver
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? name = null,Object? description = null,Object? url = null,Object? iconUrl = null,}) {
return _then(_self.copyWith(
name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
as String,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
as String,url: null == url ? _self.url : url // ignore: cast_nullable_to_non_nullable
as Uri,iconUrl: null == iconUrl ? _self.iconUrl : iconUrl // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
/// Adds pattern-matching-related methods to [Homeserver].
extension HomeserverPatterns on Homeserver {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _Homeserver value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _Homeserver() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _Homeserver value) $default,){
final _that = this;
switch (_that) {
case _Homeserver():
return $default(_that);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _Homeserver value)? $default,){
final _that = this;
switch (_that) {
case _Homeserver() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String name, String description, Uri url, String iconUrl)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _Homeserver() when $default != null:
return $default(_that.name,_that.description,_that.url,_that.iconUrl);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String name, String description, Uri url, String iconUrl) $default,) {final _that = this;
switch (_that) {
case _Homeserver():
return $default(_that.name,_that.description,_that.url,_that.iconUrl);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String name, String description, Uri url, String iconUrl)? $default,) {final _that = this;
switch (_that) {
case _Homeserver() when $default != null:
return $default(_that.name,_that.description,_that.url,_that.iconUrl);case _:
return null;
}
}
}
/// @nodoc
class _Homeserver implements Homeserver {
const _Homeserver({required this.name, required this.description, required this.url, required this.iconUrl});
@override final String name;
@override final String description;
@override final Uri url;
@override final String iconUrl;
/// Create a copy of Homeserver
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$HomeserverCopyWith<_Homeserver> get copyWith => __$HomeserverCopyWithImpl<_Homeserver>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _Homeserver&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&(identical(other.url, url) || other.url == url)&&(identical(other.iconUrl, iconUrl) || other.iconUrl == iconUrl));
}
@override
int get hashCode => Object.hash(runtimeType,name,description,url,iconUrl);
@override
String toString() {
return 'Homeserver(name: $name, description: $description, url: $url, iconUrl: $iconUrl)';
}
}
/// @nodoc
abstract mixin class _$HomeserverCopyWith<$Res> implements $HomeserverCopyWith<$Res> {
factory _$HomeserverCopyWith(_Homeserver value, $Res Function(_Homeserver) _then) = __$HomeserverCopyWithImpl;
@override @useResult
$Res call({
String name, String description, Uri url, String iconUrl
});
}
/// @nodoc
class __$HomeserverCopyWithImpl<$Res>
implements _$HomeserverCopyWith<$Res> {
__$HomeserverCopyWithImpl(this._self, this._then);
final _Homeserver _self;
final $Res Function(_Homeserver) _then;
/// Create a copy of Homeserver
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? name = null,Object? description = null,Object? url = null,Object? iconUrl = null,}) {
return _then(_Homeserver(
name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
as String,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
as String,url: null == url ? _self.url : url // ignore: cast_nullable_to_non_nullable
as Uri,iconUrl: null == iconUrl ? _self.iconUrl : iconUrl // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
// dart format on

32
lib/pages/home_page.dart Normal file
View file

@ -0,0 +1,32 @@
import "package:flutter/material.dart";
import "package:nexus/widgets/room_chat.dart";
import "package:nexus/widgets/sidebar.dart";
class HomePage extends StatelessWidget {
const HomePage({super.key});
@override
Widget build(BuildContext context) => LayoutBuilder(
builder: (context, constraints) {
final isDesktop = constraints.maxWidth > 650;
final showMembersByDefault = constraints.maxWidth > 1000;
return Scaffold(
body: Builder(
builder: (context) => Row(
children: [
if (isDesktop) Sidebar(),
Expanded(
child: RoomChat(
isDesktop: isDesktop,
showMembersByDefault: showMembersByDefault,
),
),
],
),
),
drawer: isDesktop ? null : Sidebar(),
);
},
);
}

View file

@ -0,0 +1,172 @@
import "dart:io";
import "package:flutter/material.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:flutter_svg/flutter_svg.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/helpers/homeserver_helper.dart";
import "package:nexus/helpers/launch_helper.dart";
import "package:nexus/models/homeserver.dart";
import "package:nexus/pages/login_page.dart";
import "package:nexus/widgets/appbar.dart";
import "package:nexus/widgets/divider_text.dart";
class HomeserverPage extends HookConsumerWidget {
const HomeserverPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isChecking = useState(false);
Future<void> setHomeserver(Uri? homeserver) async {
isChecking.value = true;
final messenger = ScaffoldMessenger.of(context);
final snackbar = messenger.showSnackBar(
SnackBar(
content: Text("Checking homeserver..."),
duration: Duration(days: 1),
),
);
final succeeded = homeserver == null
? false
: await ref
.watch(HomeserverHelper.provider)
.setHomeserver(
homeserver.hasScheme
? homeserver
: Uri.https(homeserver.path),
);
snackbar.close();
if (context.mounted) {
if (succeeded) {
Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => LoginPage()));
} else {
messenger.showSnackBar(
SnackBar(
content: Text(
"Homeserver verification failed. Is your homeserver down?",
style: TextStyle(
color: Theme.of(context).colorScheme.onErrorContainer,
),
),
backgroundColor: Theme.of(context).colorScheme.errorContainer,
),
);
}
}
isChecking.value = false;
}
final homeserverUrl = useTextEditingController();
return Scaffold(
appBar: Appbar(backgroundColor: Colors.transparent),
body: Center(
child: ConstrainedBox(
constraints: BoxConstraints.tight(Size.fromWidth(500)),
child: ListView(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 32),
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SvgPicture.asset("assets/icon.svg"),
SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Nexus",
style: Theme.of(context).textTheme.displayMedium,
),
Text(
"A Simple Matrix Client",
style: Theme.of(context).textTheme.headlineMedium,
),
],
),
],
),
Padding(
padding: EdgeInsetsGeometry.symmetric(vertical: 12),
child: Divider(),
),
DividerText("Enter a homeserver domain:"),
Row(
spacing: 8,
children: [
Expanded(
child: TextField(
controller: homeserverUrl,
decoration: InputDecoration(
labelText: "Homeserver URL (e.g. matrix.org)",
),
),
),
IconButton.filled(
onPressed: isChecking.value
? null
: () => setHomeserver(Uri.tryParse(homeserverUrl.text)),
icon: Icon(Icons.check),
),
],
),
DividerText("Or, choose from some popular homeservers:"),
...(<Homeserver>[
Homeserver(
name: "Matrix.org",
description:
"The Matrix.org Foundation offers the matrix.org homeserver as an easy entry point for anyone wanting to try out Matrix.",
url: Uri.https("matrix.org"),
iconUrl:
"https://raw.githubusercontent.com/element-hq/logos/refs/heads/master/matrix/matrix-favicon${Theme.brightnessOf(context) == Brightness.dark ? "-white" : ""}.png",
),
Homeserver(
name: "Federated Nexus",
description:
"Federated Nexus is a community resource hosting multiple FOSS (especially federated) services, including Matrix and Forgejo. By the same developers who made Nexus client.",
url: Uri.https("federated.nexus"),
iconUrl: "https://federated.nexus/images/icon.png",
),
Homeserver(
name: "envs.net",
description:
"envs.net is a minimalist, non-commercial shared linux system and will always be free to use.",
url: Uri.https("envs.net"),
iconUrl: "https://envs.net/favicon.ico",
),
].map(
(homeserver) => Card(
child: ListTile(
title: Text(homeserver.name),
leading: Image.network(homeserver.iconUrl, height: 32),
subtitle: Text(homeserver.description),
onTap: isChecking.value
? null
: () => setHomeserver(homeserver.url),
trailing: IconButton(
onPressed: () => ref
.watch(LaunchHelper.provider)
.launchUrl(homeserver.url),
icon: Icon(Icons.info_outline),
),
),
),
)),
SizedBox(height: 8),
TextButton(
onPressed: () => ref
.watch(LaunchHelper.provider)
.launchUrl(Uri.https("servers.joinmatrix.org")),
child: Text("See more homeservers..."),
),
],
),
),
),
);
}
}

11
lib/pages/login_page.dart Normal file
View file

@ -0,0 +1,11 @@
import "package:flutter/material.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/widgets/appbar.dart";
class LoginPage extends HookConsumerWidget {
const LoginPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) =>
Scaffold(appBar: Appbar());
}

35
lib/widgets/appbar.dart Normal file
View file

@ -0,0 +1,35 @@
import "dart:io";
import "package:flutter/material.dart";
class Appbar extends StatelessWidget implements PreferredSizeWidget {
final Widget? leading;
final Widget? title;
final Color? backgroundColor;
final double? scrolledUnderElevation;
final List<Widget> actions;
const Appbar({
super.key,
this.title,
this.backgroundColor,
this.scrolledUnderElevation,
this.leading,
this.actions = const [],
});
@override
Size get preferredSize => AppBar().preferredSize;
@override
AppBar build(BuildContext context) => AppBar(
leading: leading,
backgroundColor: backgroundColor,
scrolledUnderElevation: scrolledUnderElevation,
actionsPadding: EdgeInsets.symmetric(horizontal: 8),
title: title,
actions: [
...actions,
if (!(Platform.isAndroid || Platform.isIOS))
IconButton(onPressed: () => exit(0), icon: Icon(Icons.close)),
],
);
}

View file

@ -0,0 +1,29 @@
import "package:flutter/material.dart";
class DividerText extends StatelessWidget {
final String text;
const DividerText(this.text, {super.key});
@override
Widget build(BuildContext context) => LayoutBuilder(
builder: (context, constraints) => Row(
children: [
SizedBox(
width: 16,
child: Divider(color: Theme.of(context).colorScheme.onSurface),
),
ConstrainedBox(
constraints: BoxConstraints(maxWidth: constraints.maxWidth - 32),
child: Padding(
padding: const EdgeInsets.all(8),
child: Text(text, style: Theme.of(context).textTheme.labelLarge),
),
),
Expanded(
child: Divider(color: Theme.of(context).colorScheme.onSurface),
),
],
),
);
}

View file

@ -1,8 +1,7 @@
import "dart:io";
import "package:flutter/material.dart";
import "package:nexus/helpers/extension_helper.dart";
import "package:nexus/models/full_room.dart";
import "package:nexus/widgets/appbar.dart";
import "package:nexus/widgets/avatar_or_hash.dart";
class RoomAppbar extends StatelessWidget implements PreferredSizeWidget {
@ -22,7 +21,7 @@ class RoomAppbar extends StatelessWidget implements PreferredSizeWidget {
Size get preferredSize => AppBar().preferredSize;
@override
AppBar build(BuildContext context) => AppBar(
Widget build(BuildContext context) => Appbar(
leading: isDesktop
? AvatarOrHash(
room.avatar,
@ -33,7 +32,6 @@ class RoomAppbar extends StatelessWidget implements PreferredSizeWidget {
)
: DrawerButton(onPressed: () => onOpenDrawer(context)),
scrolledUnderElevation: 0,
actionsPadding: EdgeInsets.symmetric(horizontal: 8),
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -54,8 +52,6 @@ class RoomAppbar extends StatelessWidget implements PreferredSizeWidget {
onPressed: () => onOpenMemberList(context),
icon: Icon(Icons.people),
),
if (!(Platform.isAndroid || Platform.isIOS))
IconButton(onPressed: () => exit(0), icon: Icon(Icons.close)),
],
);
}