diff --git a/assets/i18n/en-US.json b/assets/i18n/en-US.json index b3dd52a..296d5e5 100644 --- a/assets/i18n/en-US.json +++ b/assets/i18n/en-US.json @@ -21,5 +21,6 @@ "nickname": "Nickname", "email": "Email", "fieldCannotBeEmpty": "This field cannot be empty.", - "fieldEmailAddressMustBeValid": "The email address must be valid." + "fieldEmailAddressMustBeValid": "The email address must be valid.", + "logout": "Logout" } diff --git a/lib/main.dart b/lib/main.dart index 53ee89c..899d44d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,13 +1,14 @@ import 'dart:io'; import 'package:easy_localization/easy_localization.dart' hide TextDirection; -import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:island/pods/config.dart'; import 'package:island/pods/theme.dart'; import 'package:bitsdojo_window/bitsdojo_window.dart'; +import 'package:island/pods/userinfo.dart'; import 'package:island/route.dart'; import 'package:island/widgets/app_scaffold.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -48,13 +49,22 @@ void main() async { final _appRouter = AppRouter(); -class IslandApp extends ConsumerWidget { +class IslandApp extends HookConsumerWidget { const IslandApp({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final theme = ref.watch(themeProvider); + useEffect(() { + final userNotifier = ref.read(userInfoProvider.notifier); + Future(() { + userNotifier.fetchUser(); + print('user fetched'); + }); + return null; + }, []); + return MaterialApp.router( theme: theme?.light, darkTheme: theme?.dark, diff --git a/lib/models/user.dart b/lib/models/user.dart new file mode 100644 index 0000000..a83efda --- /dev/null +++ b/lib/models/user.dart @@ -0,0 +1,42 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:island/models/file.dart'; + +part 'user.freezed.dart'; +part 'user.g.dart'; + +@freezed +abstract class SnAccount with _$SnAccount { + const factory SnAccount({ + required int id, + required String name, + required String nick, + required String language, + required bool isSuperuser, + required SnAccountProfile profile, + required DateTime createdAt, + required DateTime updatedAt, + required DateTime? deletedAt, + }) = _SnAccount; + + factory SnAccount.fromJson(Map json) => + _$SnAccountFromJson(json); +} + +@freezed +abstract class SnAccountProfile with _$SnAccountProfile { + const factory SnAccountProfile({ + required int id, + required String? firstName, + required String? middleName, + required String? lastName, + required String? bio, + required SnCloudFile? picture, + required SnCloudFile? background, + required DateTime createdAt, + required DateTime updatedAt, + required DateTime? deletedAt, + }) = _SnAccountProfile; + + factory SnAccountProfile.fromJson(Map json) => + _$SnAccountProfileFromJson(json); +} diff --git a/lib/models/user.freezed.dart b/lib/models/user.freezed.dart new file mode 100644 index 0000000..c8fb0dd --- /dev/null +++ b/lib/models/user.freezed.dart @@ -0,0 +1,398 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// 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 'user.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$SnAccount { + + int get id; String get name; String get nick; String get language; bool get isSuperuser; SnAccountProfile get profile; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; +/// Create a copy of SnAccount +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SnAccountCopyWith get copyWith => _$SnAccountCopyWithImpl(this as SnAccount, _$identity); + + /// Serializes this SnAccount to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SnAccount&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.nick, nick) || other.nick == nick)&&(identical(other.language, language) || other.language == language)&&(identical(other.isSuperuser, isSuperuser) || other.isSuperuser == isSuperuser)&&(identical(other.profile, profile) || other.profile == profile)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,name,nick,language,isSuperuser,profile,createdAt,updatedAt,deletedAt); + +@override +String toString() { + return 'SnAccount(id: $id, name: $name, nick: $nick, language: $language, isSuperuser: $isSuperuser, profile: $profile, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; +} + + +} + +/// @nodoc +abstract mixin class $SnAccountCopyWith<$Res> { + factory $SnAccountCopyWith(SnAccount value, $Res Function(SnAccount) _then) = _$SnAccountCopyWithImpl; +@useResult +$Res call({ + int id, String name, String nick, String language, bool isSuperuser, SnAccountProfile profile, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt +}); + + +$SnAccountProfileCopyWith<$Res> get profile; + +} +/// @nodoc +class _$SnAccountCopyWithImpl<$Res> + implements $SnAccountCopyWith<$Res> { + _$SnAccountCopyWithImpl(this._self, this._then); + + final SnAccount _self; + final $Res Function(SnAccount) _then; + +/// Create a copy of SnAccount +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? name = null,Object? nick = null,Object? language = null,Object? isSuperuser = null,Object? profile = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { + return _then(_self.copyWith( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as int,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,nick: null == nick ? _self.nick : nick // ignore: cast_nullable_to_non_nullable +as String,language: null == language ? _self.language : language // ignore: cast_nullable_to_non_nullable +as String,isSuperuser: null == isSuperuser ? _self.isSuperuser : isSuperuser // ignore: cast_nullable_to_non_nullable +as bool,profile: null == profile ? _self.profile : profile // ignore: cast_nullable_to_non_nullable +as SnAccountProfile,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable +as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable +as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable +as DateTime?, + )); +} +/// Create a copy of SnAccount +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$SnAccountProfileCopyWith<$Res> get profile { + + return $SnAccountProfileCopyWith<$Res>(_self.profile, (value) { + return _then(_self.copyWith(profile: value)); + }); +} +} + + +/// @nodoc +@JsonSerializable() + +class _SnAccount implements SnAccount { + const _SnAccount({required this.id, required this.name, required this.nick, required this.language, required this.isSuperuser, required this.profile, required this.createdAt, required this.updatedAt, required this.deletedAt}); + factory _SnAccount.fromJson(Map json) => _$SnAccountFromJson(json); + +@override final int id; +@override final String name; +@override final String nick; +@override final String language; +@override final bool isSuperuser; +@override final SnAccountProfile profile; +@override final DateTime createdAt; +@override final DateTime updatedAt; +@override final DateTime? deletedAt; + +/// Create a copy of SnAccount +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$SnAccountCopyWith<_SnAccount> get copyWith => __$SnAccountCopyWithImpl<_SnAccount>(this, _$identity); + +@override +Map toJson() { + return _$SnAccountToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnAccount&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.nick, nick) || other.nick == nick)&&(identical(other.language, language) || other.language == language)&&(identical(other.isSuperuser, isSuperuser) || other.isSuperuser == isSuperuser)&&(identical(other.profile, profile) || other.profile == profile)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,name,nick,language,isSuperuser,profile,createdAt,updatedAt,deletedAt); + +@override +String toString() { + return 'SnAccount(id: $id, name: $name, nick: $nick, language: $language, isSuperuser: $isSuperuser, profile: $profile, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; +} + + +} + +/// @nodoc +abstract mixin class _$SnAccountCopyWith<$Res> implements $SnAccountCopyWith<$Res> { + factory _$SnAccountCopyWith(_SnAccount value, $Res Function(_SnAccount) _then) = __$SnAccountCopyWithImpl; +@override @useResult +$Res call({ + int id, String name, String nick, String language, bool isSuperuser, SnAccountProfile profile, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt +}); + + +@override $SnAccountProfileCopyWith<$Res> get profile; + +} +/// @nodoc +class __$SnAccountCopyWithImpl<$Res> + implements _$SnAccountCopyWith<$Res> { + __$SnAccountCopyWithImpl(this._self, this._then); + + final _SnAccount _self; + final $Res Function(_SnAccount) _then; + +/// Create a copy of SnAccount +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? name = null,Object? nick = null,Object? language = null,Object? isSuperuser = null,Object? profile = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { + return _then(_SnAccount( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as int,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,nick: null == nick ? _self.nick : nick // ignore: cast_nullable_to_non_nullable +as String,language: null == language ? _self.language : language // ignore: cast_nullable_to_non_nullable +as String,isSuperuser: null == isSuperuser ? _self.isSuperuser : isSuperuser // ignore: cast_nullable_to_non_nullable +as bool,profile: null == profile ? _self.profile : profile // ignore: cast_nullable_to_non_nullable +as SnAccountProfile,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable +as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable +as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable +as DateTime?, + )); +} + +/// Create a copy of SnAccount +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$SnAccountProfileCopyWith<$Res> get profile { + + return $SnAccountProfileCopyWith<$Res>(_self.profile, (value) { + return _then(_self.copyWith(profile: value)); + }); +} +} + + +/// @nodoc +mixin _$SnAccountProfile { + + int get id; String? get firstName; String? get middleName; String? get lastName; String? get bio; SnCloudFile? get picture; SnCloudFile? get background; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; +/// Create a copy of SnAccountProfile +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SnAccountProfileCopyWith get copyWith => _$SnAccountProfileCopyWithImpl(this as SnAccountProfile, _$identity); + + /// Serializes this SnAccountProfile to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SnAccountProfile&&(identical(other.id, id) || other.id == id)&&(identical(other.firstName, firstName) || other.firstName == firstName)&&(identical(other.middleName, middleName) || other.middleName == middleName)&&(identical(other.lastName, lastName) || other.lastName == lastName)&&(identical(other.bio, bio) || other.bio == bio)&&(identical(other.picture, picture) || other.picture == picture)&&(identical(other.background, background) || other.background == background)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,firstName,middleName,lastName,bio,picture,background,createdAt,updatedAt,deletedAt); + +@override +String toString() { + return 'SnAccountProfile(id: $id, firstName: $firstName, middleName: $middleName, lastName: $lastName, bio: $bio, picture: $picture, background: $background, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; +} + + +} + +/// @nodoc +abstract mixin class $SnAccountProfileCopyWith<$Res> { + factory $SnAccountProfileCopyWith(SnAccountProfile value, $Res Function(SnAccountProfile) _then) = _$SnAccountProfileCopyWithImpl; +@useResult +$Res call({ + int id, String? firstName, String? middleName, String? lastName, String? bio, SnCloudFile? picture, SnCloudFile? background, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt +}); + + +$SnCloudFileCopyWith<$Res>? get picture;$SnCloudFileCopyWith<$Res>? get background; + +} +/// @nodoc +class _$SnAccountProfileCopyWithImpl<$Res> + implements $SnAccountProfileCopyWith<$Res> { + _$SnAccountProfileCopyWithImpl(this._self, this._then); + + final SnAccountProfile _self; + final $Res Function(SnAccountProfile) _then; + +/// Create a copy of SnAccountProfile +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? firstName = freezed,Object? middleName = freezed,Object? lastName = freezed,Object? bio = freezed,Object? picture = freezed,Object? background = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { + return _then(_self.copyWith( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as int,firstName: freezed == firstName ? _self.firstName : firstName // ignore: cast_nullable_to_non_nullable +as String?,middleName: freezed == middleName ? _self.middleName : middleName // ignore: cast_nullable_to_non_nullable +as String?,lastName: freezed == lastName ? _self.lastName : lastName // ignore: cast_nullable_to_non_nullable +as String?,bio: freezed == bio ? _self.bio : bio // ignore: cast_nullable_to_non_nullable +as String?,picture: freezed == picture ? _self.picture : picture // ignore: cast_nullable_to_non_nullable +as SnCloudFile?,background: freezed == background ? _self.background : background // ignore: cast_nullable_to_non_nullable +as SnCloudFile?,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable +as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable +as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable +as DateTime?, + )); +} +/// Create a copy of SnAccountProfile +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$SnCloudFileCopyWith<$Res>? get picture { + if (_self.picture == null) { + return null; + } + + return $SnCloudFileCopyWith<$Res>(_self.picture!, (value) { + return _then(_self.copyWith(picture: value)); + }); +}/// Create a copy of SnAccountProfile +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$SnCloudFileCopyWith<$Res>? get background { + if (_self.background == null) { + return null; + } + + return $SnCloudFileCopyWith<$Res>(_self.background!, (value) { + return _then(_self.copyWith(background: value)); + }); +} +} + + +/// @nodoc +@JsonSerializable() + +class _SnAccountProfile implements SnAccountProfile { + const _SnAccountProfile({required this.id, required this.firstName, required this.middleName, required this.lastName, required this.bio, required this.picture, required this.background, required this.createdAt, required this.updatedAt, required this.deletedAt}); + factory _SnAccountProfile.fromJson(Map json) => _$SnAccountProfileFromJson(json); + +@override final int id; +@override final String? firstName; +@override final String? middleName; +@override final String? lastName; +@override final String? bio; +@override final SnCloudFile? picture; +@override final SnCloudFile? background; +@override final DateTime createdAt; +@override final DateTime updatedAt; +@override final DateTime? deletedAt; + +/// Create a copy of SnAccountProfile +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$SnAccountProfileCopyWith<_SnAccountProfile> get copyWith => __$SnAccountProfileCopyWithImpl<_SnAccountProfile>(this, _$identity); + +@override +Map toJson() { + return _$SnAccountProfileToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnAccountProfile&&(identical(other.id, id) || other.id == id)&&(identical(other.firstName, firstName) || other.firstName == firstName)&&(identical(other.middleName, middleName) || other.middleName == middleName)&&(identical(other.lastName, lastName) || other.lastName == lastName)&&(identical(other.bio, bio) || other.bio == bio)&&(identical(other.picture, picture) || other.picture == picture)&&(identical(other.background, background) || other.background == background)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,firstName,middleName,lastName,bio,picture,background,createdAt,updatedAt,deletedAt); + +@override +String toString() { + return 'SnAccountProfile(id: $id, firstName: $firstName, middleName: $middleName, lastName: $lastName, bio: $bio, picture: $picture, background: $background, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; +} + + +} + +/// @nodoc +abstract mixin class _$SnAccountProfileCopyWith<$Res> implements $SnAccountProfileCopyWith<$Res> { + factory _$SnAccountProfileCopyWith(_SnAccountProfile value, $Res Function(_SnAccountProfile) _then) = __$SnAccountProfileCopyWithImpl; +@override @useResult +$Res call({ + int id, String? firstName, String? middleName, String? lastName, String? bio, SnCloudFile? picture, SnCloudFile? background, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt +}); + + +@override $SnCloudFileCopyWith<$Res>? get picture;@override $SnCloudFileCopyWith<$Res>? get background; + +} +/// @nodoc +class __$SnAccountProfileCopyWithImpl<$Res> + implements _$SnAccountProfileCopyWith<$Res> { + __$SnAccountProfileCopyWithImpl(this._self, this._then); + + final _SnAccountProfile _self; + final $Res Function(_SnAccountProfile) _then; + +/// Create a copy of SnAccountProfile +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? firstName = freezed,Object? middleName = freezed,Object? lastName = freezed,Object? bio = freezed,Object? picture = freezed,Object? background = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { + return _then(_SnAccountProfile( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as int,firstName: freezed == firstName ? _self.firstName : firstName // ignore: cast_nullable_to_non_nullable +as String?,middleName: freezed == middleName ? _self.middleName : middleName // ignore: cast_nullable_to_non_nullable +as String?,lastName: freezed == lastName ? _self.lastName : lastName // ignore: cast_nullable_to_non_nullable +as String?,bio: freezed == bio ? _self.bio : bio // ignore: cast_nullable_to_non_nullable +as String?,picture: freezed == picture ? _self.picture : picture // ignore: cast_nullable_to_non_nullable +as SnCloudFile?,background: freezed == background ? _self.background : background // ignore: cast_nullable_to_non_nullable +as SnCloudFile?,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable +as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable +as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable +as DateTime?, + )); +} + +/// Create a copy of SnAccountProfile +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$SnCloudFileCopyWith<$Res>? get picture { + if (_self.picture == null) { + return null; + } + + return $SnCloudFileCopyWith<$Res>(_self.picture!, (value) { + return _then(_self.copyWith(picture: value)); + }); +}/// Create a copy of SnAccountProfile +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$SnCloudFileCopyWith<$Res>? get background { + if (_self.background == null) { + return null; + } + + return $SnCloudFileCopyWith<$Res>(_self.background!, (value) { + return _then(_self.copyWith(background: value)); + }); +} +} + +// dart format on diff --git a/lib/models/user.g.dart b/lib/models/user.g.dart new file mode 100644 index 0000000..2d25894 --- /dev/null +++ b/lib/models/user.g.dart @@ -0,0 +1,74 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'user.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_SnAccount _$SnAccountFromJson(Map json) => _SnAccount( + id: (json['id'] as num).toInt(), + name: json['name'] as String, + nick: json['nick'] as String, + language: json['language'] as String, + isSuperuser: json['is_superuser'] as bool, + profile: SnAccountProfile.fromJson(json['profile'] as Map), + createdAt: DateTime.parse(json['created_at'] as String), + updatedAt: DateTime.parse(json['updated_at'] as String), + deletedAt: + json['deleted_at'] == null + ? null + : DateTime.parse(json['deleted_at'] as String), +); + +Map _$SnAccountToJson(_SnAccount instance) => + { + 'id': instance.id, + 'name': instance.name, + 'nick': instance.nick, + 'language': instance.language, + 'is_superuser': instance.isSuperuser, + 'profile': instance.profile.toJson(), + 'created_at': instance.createdAt.toIso8601String(), + 'updated_at': instance.updatedAt.toIso8601String(), + 'deleted_at': instance.deletedAt?.toIso8601String(), + }; + +_SnAccountProfile _$SnAccountProfileFromJson(Map json) => + _SnAccountProfile( + id: (json['id'] as num).toInt(), + firstName: json['first_name'] as String?, + middleName: json['middle_name'] as String?, + lastName: json['last_name'] as String?, + bio: json['bio'] as String?, + picture: + json['picture'] == null + ? null + : SnCloudFile.fromJson(json['picture'] as Map), + background: + json['background'] == null + ? null + : SnCloudFile.fromJson( + json['background'] as Map, + ), + createdAt: DateTime.parse(json['created_at'] as String), + updatedAt: DateTime.parse(json['updated_at'] as String), + deletedAt: + json['deleted_at'] == null + ? null + : DateTime.parse(json['deleted_at'] as String), + ); + +Map _$SnAccountProfileToJson(_SnAccountProfile instance) => + { + 'id': instance.id, + 'first_name': instance.firstName, + 'middle_name': instance.middleName, + 'last_name': instance.lastName, + 'bio': instance.bio, + 'picture': instance.picture?.toJson(), + 'background': instance.background?.toJson(), + 'created_at': instance.createdAt.toIso8601String(), + 'updated_at': instance.updatedAt.toIso8601String(), + 'deleted_at': instance.deletedAt?.toIso8601String(), + }; diff --git a/lib/pods/userinfo.dart b/lib/pods/userinfo.dart new file mode 100644 index 0000000..405a2e8 --- /dev/null +++ b/lib/pods/userinfo.dart @@ -0,0 +1,40 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:island/models/user.dart'; +import 'package:island/pods/config.dart'; +import 'package:island/pods/network.dart'; + +class UserInfoNotifier extends StateNotifier> { + final Ref _ref; + + UserInfoNotifier(this._ref) : super(const AsyncValue.data(null)); + + Future getAccessToken() async { + final prefs = _ref.read(sharedPreferencesProvider); + return prefs.getString('dyn_user_atk'); + } + + Future fetchUser() async { + state = const AsyncValue.loading(); + try { + final client = _ref.read(apiClientProvider); + final response = await client.get('/accounts/me'); + final user = SnAccount.fromJson(response.data); + state = AsyncValue.data(user); + } catch (error, stackTrace) { + print('Failed to fetch user: $error'); + state = AsyncValue.error(error, stackTrace); + } + } + + Future logOut() async { + state = const AsyncValue.data(null); + final prefs = _ref.read(sharedPreferencesProvider); + await prefs.remove(kTokenPairStoreKey); + } +} + +final userInfoProvider = + StateNotifierProvider>( + (ref) => UserInfoNotifier(ref), + ); diff --git a/lib/route.dart b/lib/route.dart index 4d3fc59..95dab2c 100644 --- a/lib/route.dart +++ b/lib/route.dart @@ -8,8 +8,17 @@ class AppRouter extends RootStackRouter { @override List get routes => [ - AutoRoute(page: ExploreRoute.page, path: '/'), - AutoRoute(page: AccountRoute.page, path: '/account'), + AutoRoute( + page: ExploreRoute.page, + path: '/', + meta: {'bottomNav': true}, + initial: true, + ), + AutoRoute( + page: AccountRoute.page, + path: '/account', + meta: {'bottomNav': true}, + ), AutoRoute(page: LoginRoute.page, path: '/auth/login'), AutoRoute(page: CreateAccountRoute.page, path: '/auth/create-account'), ]; diff --git a/lib/screens/account.dart b/lib/screens/account.dart index ba74424..4932f9d 100644 --- a/lib/screens/account.dart +++ b/lib/screens/account.dart @@ -1,12 +1,102 @@ import 'package:auto_route/auto_route.dart'; +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:island/pods/userinfo.dart'; import 'package:island/route.gr.dart'; import 'package:island/widgets/app_scaffold.dart'; +import 'package:island/widgets/content/cloud_files.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import 'package:styled_widget/styled_widget.dart'; @RoutePage() -class AccountScreen extends StatelessWidget { +class AccountScreen extends HookConsumerWidget { const AccountScreen({super.key}); + @override + Widget build(BuildContext context, WidgetRef ref) { + final user = ref.watch(userInfoProvider); + + if (!user.hasValue) return _UnauthorizedAccountScreen(); + + return AppScaffold( + appBar: AppBar(title: const Text('Account')), + body: SingleChildScrollView( + child: Column( + children: [ + Card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (user.value!.profile.background != null) + ClipRRect( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(8), + topRight: Radius.circular(8), + ), + child: AspectRatio( + aspectRatio: 16 / 7, + child: CloudFileWidget( + item: user.value!.profile.background!, + fit: BoxFit.cover, + ), + ), + ), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + spacing: 16, + children: [ + ProfilePictureWidget( + item: user.value!.profile.picture, + radius: 24, + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + spacing: 4, + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text(user.value!.nick).bold().fontSize(16), + Text('@${user.value!.name}'), + ], + ), + Text( + user.value!.profile.bio ?? 'No description yet.', + ), + ], + ), + ], + ).padding(horizontal: 16, vertical: 16), + ], + ), + ), + ListTile( + leading: const Icon(LucideIcons.logOut), + trailing: const Icon(LucideIcons.chevronRight), + contentPadding: EdgeInsets.symmetric(horizontal: 24), + title: Text('logout').tr(), + subtitle: Text('Log out of your account.'), + onTap: () { + final userNotifier = ref.read(userInfoProvider.notifier); + userNotifier.logOut(); + }, + ), + ], + ).padding( + horizontal: 8, + top: 8, + bottom: MediaQuery.of(context).padding.bottom, + ), + ), + ); + } +} + +class _UnauthorizedAccountScreen extends StatelessWidget { + const _UnauthorizedAccountScreen(); + @override Widget build(BuildContext context) { return AppScaffold( @@ -14,15 +104,23 @@ class AccountScreen extends StatelessWidget { body: Column( children: [ ListTile( - title: Text('Login'), + leading: const Icon(LucideIcons.userPlus), + trailing: const Icon(LucideIcons.chevronRight), + title: Text('createAccount').tr(), + subtitle: Text('New to here? We got you covered!'), + contentPadding: EdgeInsets.symmetric(horizontal: 24), onTap: () { - context.router.push(LoginRoute()); + context.router.push(CreateAccountRoute()); }, ), ListTile( - title: Text('Create an account'), + leading: const Icon(LucideIcons.logIn), + trailing: const Icon(LucideIcons.chevronRight), + subtitle: Text('Existing user? We\'re welcome you back!'), + contentPadding: EdgeInsets.symmetric(horizontal: 24), + title: Text('login').tr(), onTap: () { - context.router.push(CreateAccountRoute()); + context.router.push(LoginRoute()); }, ), ], diff --git a/lib/screens/auth/create_account.dart b/lib/screens/auth/create_account.dart index bac4521..32d5635 100644 --- a/lib/screens/auth/create_account.dart +++ b/lib/screens/auth/create_account.dart @@ -9,7 +9,7 @@ import 'package:island/pods/network.dart'; import 'package:island/route.gr.dart'; import 'package:island/widgets/alert.dart'; import 'package:island/widgets/app_scaffold.dart'; -import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; +import 'package:lucide_icons/lucide_icons.dart'; import 'package:styled_widget/styled_widget.dart'; import 'package:url_launcher/url_launcher_string.dart'; @@ -83,7 +83,7 @@ class CreateAccountScreen extends HookConsumerWidget { alignment: Alignment.centerLeft, child: CircleAvatar( radius: 26, - child: Icon(MdiIcons.accountPlus, size: 28), + child: const Icon(LucideIcons.userPlus, size: 28), ).padding(bottom: 8), ), Text( @@ -223,7 +223,10 @@ class CreateAccountScreen extends HookConsumerWidget { children: [ Text('termAcceptLink').tr(), const Gap(4), - Icon(MdiIcons.launch, size: 14), + const Icon( + LucideIcons.externalLink, + size: 14, + ), ], ), onTap: () { @@ -250,7 +253,7 @@ class CreateAccountScreen extends HookConsumerWidget { mainAxisSize: MainAxisSize.min, children: [ Text("next").tr(), - Icon(MdiIcons.chevronRight), + const Icon(LucideIcons.chevronRight), ], ), ), diff --git a/lib/screens/auth/login.dart b/lib/screens/auth/login.dart index 1728bce..aaac719 100644 --- a/lib/screens/auth/login.dart +++ b/lib/screens/auth/login.dart @@ -8,9 +8,10 @@ import 'package:gap/gap.dart'; import 'package:island/models/auth.dart'; import 'package:island/pods/config.dart'; import 'package:island/pods/network.dart'; +import 'package:island/pods/userinfo.dart'; import 'package:island/widgets/alert.dart'; import 'package:island/widgets/app_scaffold.dart'; -import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; +import 'package:lucide_icons/lucide_icons.dart'; import 'package:styled_widget/styled_widget.dart'; import 'package:url_launcher/url_launcher_string.dart'; @@ -128,7 +129,8 @@ class _LoginCheckScreen extends HookConsumerWidget { setTokenPair(ref.watch(sharedPreferencesProvider), atk, rtk); ref.invalidate(tokenPairProvider); if (!context.mounted) return; - // TODO userinfo + final userNotifier = ref.read(userInfoProvider.notifier); + userNotifier.fetchUser(); Navigator.pop(context, true); } catch (err) { showErrorAlert(err); @@ -145,7 +147,7 @@ class _LoginCheckScreen extends HookConsumerWidget { alignment: Alignment.centerLeft, child: CircleAvatar( radius: 26, - child: Icon(MdiIcons.formTextboxPassword, size: 28), + child: const Icon(LucideIcons.squareAsterisk, size: 28), ).padding(bottom: 8), ), Text( @@ -178,7 +180,10 @@ class _LoginCheckScreen extends HookConsumerWidget { onPressed: isBusy.value ? null : () => performCheckTicket(), child: Row( mainAxisSize: MainAxisSize.min, - children: [Text('next').tr(), Icon(MdiIcons.chevronRight)], + children: [ + Text('next').tr(), + const Icon(LucideIcons.chevronRight), + ], ), ), ], @@ -242,7 +247,7 @@ class _LoginPickerScreen extends HookConsumerWidget { alignment: Alignment.centerLeft, child: CircleAvatar( radius: 26, - child: Icon(MdiIcons.security, size: 28), + child: const Icon(LucideIcons.lock, size: 28), ).padding(bottom: 8), ), Text( @@ -260,7 +265,7 @@ class _LoginPickerScreen extends HookConsumerWidget { shape: const RoundedRectangleBorder( borderRadius: BorderRadius.all(Radius.circular(8)), ), - secondary: Icon(MdiIcons.fileQuestion), + secondary: const Icon(LucideIcons.shieldQuestion), title: Text('unknown').tr(), enabled: !ticket!.blacklistFactors.contains(x.id), value: factorPicked.value == x.id, @@ -288,7 +293,10 @@ class _LoginPickerScreen extends HookConsumerWidget { onPressed: isBusy.value ? null : () => performGetFactorCode(), child: Row( mainAxisSize: MainAxisSize.min, - children: [Text('next'.tr()), Icon(MdiIcons.chevronRight)], + children: [ + Text('next'.tr()), + const Icon(LucideIcons.chevronRight), + ], ), ), ], @@ -375,7 +383,7 @@ class _LoginLookupScreen extends HookConsumerWidget { alignment: Alignment.centerLeft, child: CircleAvatar( radius: 26, - child: Icon(MdiIcons.login, size: 28), + child: const Icon(LucideIcons.logIn, size: 28), ).padding(bottom: 8), ), Text( @@ -409,7 +417,10 @@ class _LoginLookupScreen extends HookConsumerWidget { onPressed: isBusy.value ? null : () => performNewTicket(), child: Row( mainAxisSize: MainAxisSize.min, - children: [Text('next').tr(), Icon(MdiIcons.chevronRight)], + children: [ + Text('next').tr(), + const Icon(LucideIcons.chevronRight), + ], ), ), ], @@ -440,7 +451,7 @@ class _LoginLookupScreen extends HookConsumerWidget { children: [ Text('termAcceptLink'.tr()), const Gap(4), - Icon(MdiIcons.launch, size: 14), + const Icon(LucideIcons.externalLink, size: 14), ], ), onTap: () { diff --git a/lib/widgets/app_scaffold.dart b/lib/widgets/app_scaffold.dart index d5dfa6d..619983b 100644 --- a/lib/widgets/app_scaffold.dart +++ b/lib/widgets/app_scaffold.dart @@ -4,9 +4,11 @@ import 'package:auto_route/auto_route.dart'; import 'package:bitsdojo_window/bitsdojo_window.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:island/route.dart'; import 'package:island/route.gr.dart'; -import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; +import 'package:lucide_icons/lucide_icons.dart'; import 'package:path_provider/path_provider.dart'; import 'package:responsive_framework/responsive_framework.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -79,28 +81,54 @@ class WindowScaffold extends StatelessWidget { ), ); } + return Scaffold( extendBody: true, extendBodyBehindAppBar: true, backgroundColor: Colors.transparent, body: SizedBox.expand(child: child), key: rootScaffoldKey, - bottomNavigationBar: NavigationBar( - destinations: [ - NavigationDestination(icon: Icon(MdiIcons.compass), label: 'Explore'), - NavigationDestination(icon: Icon(MdiIcons.account), label: 'Account'), - ], - onDestinationSelected: (idx) { - switch (idx) { - case 0: - router.replace(ExploreRoute()); - break; - case 1: - router.replace(AccountRoute()); - break; - } - }, - ), + bottomNavigationBar: + router.current.meta['bottomNav'] == true || router.currentPath == '/' + ? AppBottomNavigationBar(router: router) + : null, + ); + } +} + +class AppBottomNavigationBar extends HookConsumerWidget { + const AppBottomNavigationBar({super.key, required this.router}); + + final AppRouter router; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final destination = useState(0); + + return NavigationBar( + selectedIndex: destination.value, + destinations: [ + NavigationDestination( + icon: Icon(LucideIcons.compass), + label: 'Explore', + ), + NavigationDestination( + icon: Icon(LucideIcons.userCircle), + label: 'Account', + ), + ], + onDestinationSelected: (idx) { + switch (idx) { + case 0: + destination.value = idx; + router.replace(ExploreRoute()); + break; + case 1: + destination.value = idx; + router.replace(AccountRoute()); + break; + } + }, ); } } diff --git a/lib/widgets/content/cloud_files.dart b/lib/widgets/content/cloud_files.dart index 828d15d..05dd7e1 100644 --- a/lib/widgets/content/cloud_files.dart +++ b/lib/widgets/content/cloud_files.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:island/models/file.dart'; import 'package:island/pods/config.dart'; -import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; +import 'package:lucide_icons/lucide_icons.dart'; import 'image.dart'; import 'video.dart'; @@ -35,7 +35,7 @@ class CloudFileWidget extends ConsumerWidget { ), ); default: - return Placeholder(); + return Text('Unable render for ${item.mimeType}'); } } } @@ -56,7 +56,7 @@ class ProfilePictureWidget extends ConsumerWidget { color: Theme.of(context).colorScheme.primaryContainer, child: item == null - ? Icon(MdiIcons.account) + ? Icon(LucideIcons.userCircle) : CloudFileWidget(item: item!), ), ); diff --git a/pubspec.lock b/pubspec.lock index f13f949..29128ac 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -733,6 +733,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" + lucide_icons: + dependency: "direct main" + description: + name: lucide_icons + sha256: ad24d0fd65707e48add30bebada7d90bff2a1bba0a72d6e9b19d44246b0e83c4 + url: "https://pub.dev" + source: hosted + version: "0.257.0" markdown: dependency: "direct main" description: @@ -757,14 +765,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.11.1" - material_design_icons_flutter: - dependency: "direct main" - description: - name: material_design_icons_flutter - sha256: "6f986b7a51f3ad4c00e33c5c84e8de1bdd140489bbcdc8b66fc1283dad4dea5a" - url: "https://pub.dev" - source: hosted - version: "7.0.7296" media_kit: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 049df38..b489968 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -63,13 +63,13 @@ dependencies: media_kit_libs_video: ^1.0.6 flutter_cache_manager: ^3.4.1 flutter_platform_alert: ^0.7.0 - material_design_icons_flutter: ^7.0.7296 email_validator: ^3.0.0 easy_localization: ^3.0.7+1 flutter_inappwebview: ^6.1.5 animations: ^2.0.11 package_info_plus: ^8.3.0 device_info_plus: ^11.4.0 + lucide_icons: ^0.257.0 dev_dependencies: flutter_test: