diff --git a/assets/i18n/en-US.json b/assets/i18n/en-US.json new file mode 100644 index 0000000..b3dd52a --- /dev/null +++ b/assets/i18n/en-US.json @@ -0,0 +1,25 @@ +{ + "login": "Login", + "forgotPassword": "Forgot password", + "loginPickFactor": "Pick a factor", + "loginMultiFactor": { + "one": "{} step left", + "other": "{} steps left" + }, + "loginEnterPassword": "Enter the code", + "loginSuccess": "Logged in as {}", + "loginGreeting": "Welcome back!", + "username": "Username", + "usernameLookupHint": "We also take your email address.", + "unknown": "Unknown", + "termAcceptNextWithAgree": "By continuing, you agree to our terms of services and other terms and conditions.", + "termAcceptLink": "Check them out", + "loginResetPasswordHint": "Provide your username to receive a password reset link.", + "password": "Password", + "next": "Next", + "createAccount": "Create an Account", + "nickname": "Nickname", + "email": "Email", + "fieldCannotBeEmpty": "This field cannot be empty.", + "fieldEmailAddressMustBeValid": "The email address must be valid." +} diff --git a/assets/i18n/zh-CN.json b/assets/i18n/zh-CN.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/assets/i18n/zh-CN.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 3ccaaeb..1daeec6 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,5 +1,14 @@ PODS: + - device_info_plus (0.0.1): + - Flutter - Flutter (1.0.0) + - flutter_inappwebview_ios (0.0.1): + - Flutter + - flutter_inappwebview_ios/Core (= 0.0.1) + - OrderedSet (~> 6.0.3) + - flutter_inappwebview_ios/Core (0.0.1): + - Flutter + - OrderedSet (~> 6.0.3) - flutter_platform_alert (0.0.1): - Flutter - Kingfisher (8.3.1) @@ -7,6 +16,7 @@ PODS: - Flutter - media_kit_video (0.0.1): - Flutter + - OrderedSet (6.0.3) - package_info_plus (0.4.5): - Flutter - path_provider_foundation (0.0.1): @@ -26,7 +36,9 @@ PODS: - Flutter DEPENDENCIES: + - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - Flutter (from `Flutter`) + - flutter_inappwebview_ios (from `.symlinks/plugins/flutter_inappwebview_ios/ios`) - flutter_platform_alert (from `.symlinks/plugins/flutter_platform_alert/ios`) - Kingfisher (~> 8.0) - media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`) @@ -42,10 +54,15 @@ DEPENDENCIES: SPEC REPOS: trunk: - Kingfisher + - OrderedSet EXTERNAL SOURCES: + device_info_plus: + :path: ".symlinks/plugins/device_info_plus/ios" Flutter: :path: Flutter + flutter_inappwebview_ios: + :path: ".symlinks/plugins/flutter_inappwebview_ios/ios" flutter_platform_alert: :path: ".symlinks/plugins/flutter_platform_alert/ios" media_kit_libs_ios_video: @@ -68,11 +85,14 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/wakelock_plus/ios" SPEC CHECKSUMS: + device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 + flutter_inappwebview_ios: b89ba3482b96fb25e00c967aae065701b66e9b99 flutter_platform_alert: bf3b5fcd4ac14bd637e20527e9c471633071afd3 Kingfisher: 3204d23de16b5ea53541c44ca5a8efb55741dec3 media_kit_libs_ios_video: 5a18affdb97d1f5d466dc79988b13eff6c5e2854 media_kit_video: 1746e198cb697d1ffb734b1d05ec429d1fcd1474 + OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94 package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 146020e..e8e0fef 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -317,14 +317,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; diff --git a/lib/main.dart b/lib/main.dart index 87978ff..53ee89c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,5 +1,7 @@ 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'; @@ -12,6 +14,7 @@ import 'package:shared_preferences/shared_preferences.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); + await EasyLocalization.ensureInitialized(); final prefs = await SharedPreferences.getInstance(); if (!kIsWeb && (Platform.isMacOS || Platform.isLinux || Platform.isWindows)) { @@ -27,26 +30,44 @@ void main() async { runApp( ProviderScope( overrides: [sharedPreferencesProvider.overrideWithValue(prefs)], - child: IslandApp(), + child: Directionality( + textDirection: TextDirection.ltr, + child: EasyLocalization( + supportedLocales: [Locale('en', 'US')], + path: 'assets/i18n', + fallbackLocale: Locale('en', 'US'), + useFallbackTranslations: true, + child: Overlay( + initialEntries: [OverlayEntry(builder: (_) => IslandApp())], + ), + ), + ), ), ); } -class IslandApp extends ConsumerWidget { - IslandApp({super.key}); +final _appRouter = AppRouter(); - final _appRouter = AppRouter(); +class IslandApp extends ConsumerWidget { + const IslandApp({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final theme = ref.watch(themeProvider); + return MaterialApp.router( theme: theme?.light, darkTheme: theme?.dark, themeMode: ThemeMode.system, routerConfig: _appRouter.config(), + supportedLocales: context.supportedLocales, + localizationsDelegates: [...context.localizationDelegates], + locale: context.locale, builder: (context, child) { - return WindowScaffold(child: child ?? const SizedBox.shrink()); + return WindowScaffold( + router: _appRouter, + child: child ?? const SizedBox.shrink(), + ); }, ); } diff --git a/lib/models/auth.dart b/lib/models/auth.dart new file mode 100644 index 0000000..805dbe8 --- /dev/null +++ b/lib/models/auth.dart @@ -0,0 +1,52 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'auth.freezed.dart'; +part 'auth.g.dart'; + +@freezed +abstract class AppTokenPair with _$AppTokenPair { + const factory AppTokenPair({ + required String accessToken, + required String refreshToken, + }) = _AppTokenPair; + + factory AppTokenPair.fromJson(Map json) => + _$AppTokenPairFromJson(json); +} + +@freezed +abstract class SnAuthChallenge with _$SnAuthChallenge { + const factory SnAuthChallenge({ + required String id, + required DateTime expiredAt, + required int stepRemain, + required int stepTotal, + required List blacklistFactors, + required List audiences, + required List scopes, + required String ipAddress, + required String userAgent, + required String? deviceId, + required String? nonce, + required DateTime createdAt, + required DateTime updatedAt, + required DateTime? deletedAt, + }) = _SnAuthChallenge; + + factory SnAuthChallenge.fromJson(Map json) => + _$SnAuthChallengeFromJson(json); +} + +@freezed +abstract class SnAuthFactor with _$SnAuthFactor { + const factory SnAuthFactor({ + required int id, + required int type, + required DateTime createdAt, + required DateTime updatedAt, + required DateTime? deletedAt, + }) = _SnAuthFactor; + + factory SnAuthFactor.fromJson(Map json) => + _$SnAuthFactorFromJson(json); +} diff --git a/lib/models/auth.freezed.dart b/lib/models/auth.freezed.dart new file mode 100644 index 0000000..86721f2 --- /dev/null +++ b/lib/models/auth.freezed.dart @@ -0,0 +1,486 @@ +// 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 'auth.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$AppTokenPair { + + String get accessToken; String get refreshToken; +/// Create a copy of AppTokenPair +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$AppTokenPairCopyWith get copyWith => _$AppTokenPairCopyWithImpl(this as AppTokenPair, _$identity); + + /// Serializes this AppTokenPair to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is AppTokenPair&&(identical(other.accessToken, accessToken) || other.accessToken == accessToken)&&(identical(other.refreshToken, refreshToken) || other.refreshToken == refreshToken)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,accessToken,refreshToken); + +@override +String toString() { + return 'AppTokenPair(accessToken: $accessToken, refreshToken: $refreshToken)'; +} + + +} + +/// @nodoc +abstract mixin class $AppTokenPairCopyWith<$Res> { + factory $AppTokenPairCopyWith(AppTokenPair value, $Res Function(AppTokenPair) _then) = _$AppTokenPairCopyWithImpl; +@useResult +$Res call({ + String accessToken, String refreshToken +}); + + + + +} +/// @nodoc +class _$AppTokenPairCopyWithImpl<$Res> + implements $AppTokenPairCopyWith<$Res> { + _$AppTokenPairCopyWithImpl(this._self, this._then); + + final AppTokenPair _self; + final $Res Function(AppTokenPair) _then; + +/// Create a copy of AppTokenPair +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? accessToken = null,Object? refreshToken = null,}) { + return _then(_self.copyWith( +accessToken: null == accessToken ? _self.accessToken : accessToken // ignore: cast_nullable_to_non_nullable +as String,refreshToken: null == refreshToken ? _self.refreshToken : refreshToken // ignore: cast_nullable_to_non_nullable +as String, + )); +} + +} + + +/// @nodoc +@JsonSerializable() + +class _AppTokenPair implements AppTokenPair { + const _AppTokenPair({required this.accessToken, required this.refreshToken}); + factory _AppTokenPair.fromJson(Map json) => _$AppTokenPairFromJson(json); + +@override final String accessToken; +@override final String refreshToken; + +/// Create a copy of AppTokenPair +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$AppTokenPairCopyWith<_AppTokenPair> get copyWith => __$AppTokenPairCopyWithImpl<_AppTokenPair>(this, _$identity); + +@override +Map toJson() { + return _$AppTokenPairToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _AppTokenPair&&(identical(other.accessToken, accessToken) || other.accessToken == accessToken)&&(identical(other.refreshToken, refreshToken) || other.refreshToken == refreshToken)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,accessToken,refreshToken); + +@override +String toString() { + return 'AppTokenPair(accessToken: $accessToken, refreshToken: $refreshToken)'; +} + + +} + +/// @nodoc +abstract mixin class _$AppTokenPairCopyWith<$Res> implements $AppTokenPairCopyWith<$Res> { + factory _$AppTokenPairCopyWith(_AppTokenPair value, $Res Function(_AppTokenPair) _then) = __$AppTokenPairCopyWithImpl; +@override @useResult +$Res call({ + String accessToken, String refreshToken +}); + + + + +} +/// @nodoc +class __$AppTokenPairCopyWithImpl<$Res> + implements _$AppTokenPairCopyWith<$Res> { + __$AppTokenPairCopyWithImpl(this._self, this._then); + + final _AppTokenPair _self; + final $Res Function(_AppTokenPair) _then; + +/// Create a copy of AppTokenPair +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? accessToken = null,Object? refreshToken = null,}) { + return _then(_AppTokenPair( +accessToken: null == accessToken ? _self.accessToken : accessToken // ignore: cast_nullable_to_non_nullable +as String,refreshToken: null == refreshToken ? _self.refreshToken : refreshToken // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + + +/// @nodoc +mixin _$SnAuthChallenge { + + String get id; DateTime get expiredAt; int get stepRemain; int get stepTotal; List get blacklistFactors; List get audiences; List get scopes; String get ipAddress; String get userAgent; String? get deviceId; String? get nonce; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; +/// Create a copy of SnAuthChallenge +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SnAuthChallengeCopyWith get copyWith => _$SnAuthChallengeCopyWithImpl(this as SnAuthChallenge, _$identity); + + /// Serializes this SnAuthChallenge to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SnAuthChallenge&&(identical(other.id, id) || other.id == id)&&(identical(other.expiredAt, expiredAt) || other.expiredAt == expiredAt)&&(identical(other.stepRemain, stepRemain) || other.stepRemain == stepRemain)&&(identical(other.stepTotal, stepTotal) || other.stepTotal == stepTotal)&&const DeepCollectionEquality().equals(other.blacklistFactors, blacklistFactors)&&const DeepCollectionEquality().equals(other.audiences, audiences)&&const DeepCollectionEquality().equals(other.scopes, scopes)&&(identical(other.ipAddress, ipAddress) || other.ipAddress == ipAddress)&&(identical(other.userAgent, userAgent) || other.userAgent == userAgent)&&(identical(other.deviceId, deviceId) || other.deviceId == deviceId)&&(identical(other.nonce, nonce) || other.nonce == nonce)&&(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,expiredAt,stepRemain,stepTotal,const DeepCollectionEquality().hash(blacklistFactors),const DeepCollectionEquality().hash(audiences),const DeepCollectionEquality().hash(scopes),ipAddress,userAgent,deviceId,nonce,createdAt,updatedAt,deletedAt); + +@override +String toString() { + return 'SnAuthChallenge(id: $id, expiredAt: $expiredAt, stepRemain: $stepRemain, stepTotal: $stepTotal, blacklistFactors: $blacklistFactors, audiences: $audiences, scopes: $scopes, ipAddress: $ipAddress, userAgent: $userAgent, deviceId: $deviceId, nonce: $nonce, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; +} + + +} + +/// @nodoc +abstract mixin class $SnAuthChallengeCopyWith<$Res> { + factory $SnAuthChallengeCopyWith(SnAuthChallenge value, $Res Function(SnAuthChallenge) _then) = _$SnAuthChallengeCopyWithImpl; +@useResult +$Res call({ + String id, DateTime expiredAt, int stepRemain, int stepTotal, List blacklistFactors, List audiences, List scopes, String ipAddress, String userAgent, String? deviceId, String? nonce, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt +}); + + + + +} +/// @nodoc +class _$SnAuthChallengeCopyWithImpl<$Res> + implements $SnAuthChallengeCopyWith<$Res> { + _$SnAuthChallengeCopyWithImpl(this._self, this._then); + + final SnAuthChallenge _self; + final $Res Function(SnAuthChallenge) _then; + +/// Create a copy of SnAuthChallenge +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? expiredAt = null,Object? stepRemain = null,Object? stepTotal = null,Object? blacklistFactors = null,Object? audiences = null,Object? scopes = null,Object? ipAddress = null,Object? userAgent = null,Object? deviceId = freezed,Object? nonce = 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 String,expiredAt: null == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable +as DateTime,stepRemain: null == stepRemain ? _self.stepRemain : stepRemain // ignore: cast_nullable_to_non_nullable +as int,stepTotal: null == stepTotal ? _self.stepTotal : stepTotal // ignore: cast_nullable_to_non_nullable +as int,blacklistFactors: null == blacklistFactors ? _self.blacklistFactors : blacklistFactors // ignore: cast_nullable_to_non_nullable +as List,audiences: null == audiences ? _self.audiences : audiences // ignore: cast_nullable_to_non_nullable +as List,scopes: null == scopes ? _self.scopes : scopes // ignore: cast_nullable_to_non_nullable +as List,ipAddress: null == ipAddress ? _self.ipAddress : ipAddress // ignore: cast_nullable_to_non_nullable +as String,userAgent: null == userAgent ? _self.userAgent : userAgent // ignore: cast_nullable_to_non_nullable +as String,deviceId: freezed == deviceId ? _self.deviceId : deviceId // ignore: cast_nullable_to_non_nullable +as String?,nonce: freezed == nonce ? _self.nonce : nonce // ignore: cast_nullable_to_non_nullable +as String?,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?, + )); +} + +} + + +/// @nodoc +@JsonSerializable() + +class _SnAuthChallenge implements SnAuthChallenge { + const _SnAuthChallenge({required this.id, required this.expiredAt, required this.stepRemain, required this.stepTotal, required final List blacklistFactors, required final List audiences, required final List scopes, required this.ipAddress, required this.userAgent, required this.deviceId, required this.nonce, required this.createdAt, required this.updatedAt, required this.deletedAt}): _blacklistFactors = blacklistFactors,_audiences = audiences,_scopes = scopes; + factory _SnAuthChallenge.fromJson(Map json) => _$SnAuthChallengeFromJson(json); + +@override final String id; +@override final DateTime expiredAt; +@override final int stepRemain; +@override final int stepTotal; + final List _blacklistFactors; +@override List get blacklistFactors { + if (_blacklistFactors is EqualUnmodifiableListView) return _blacklistFactors; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_blacklistFactors); +} + + final List _audiences; +@override List get audiences { + if (_audiences is EqualUnmodifiableListView) return _audiences; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_audiences); +} + + final List _scopes; +@override List get scopes { + if (_scopes is EqualUnmodifiableListView) return _scopes; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_scopes); +} + +@override final String ipAddress; +@override final String userAgent; +@override final String? deviceId; +@override final String? nonce; +@override final DateTime createdAt; +@override final DateTime updatedAt; +@override final DateTime? deletedAt; + +/// Create a copy of SnAuthChallenge +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$SnAuthChallengeCopyWith<_SnAuthChallenge> get copyWith => __$SnAuthChallengeCopyWithImpl<_SnAuthChallenge>(this, _$identity); + +@override +Map toJson() { + return _$SnAuthChallengeToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnAuthChallenge&&(identical(other.id, id) || other.id == id)&&(identical(other.expiredAt, expiredAt) || other.expiredAt == expiredAt)&&(identical(other.stepRemain, stepRemain) || other.stepRemain == stepRemain)&&(identical(other.stepTotal, stepTotal) || other.stepTotal == stepTotal)&&const DeepCollectionEquality().equals(other._blacklistFactors, _blacklistFactors)&&const DeepCollectionEquality().equals(other._audiences, _audiences)&&const DeepCollectionEquality().equals(other._scopes, _scopes)&&(identical(other.ipAddress, ipAddress) || other.ipAddress == ipAddress)&&(identical(other.userAgent, userAgent) || other.userAgent == userAgent)&&(identical(other.deviceId, deviceId) || other.deviceId == deviceId)&&(identical(other.nonce, nonce) || other.nonce == nonce)&&(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,expiredAt,stepRemain,stepTotal,const DeepCollectionEquality().hash(_blacklistFactors),const DeepCollectionEquality().hash(_audiences),const DeepCollectionEquality().hash(_scopes),ipAddress,userAgent,deviceId,nonce,createdAt,updatedAt,deletedAt); + +@override +String toString() { + return 'SnAuthChallenge(id: $id, expiredAt: $expiredAt, stepRemain: $stepRemain, stepTotal: $stepTotal, blacklistFactors: $blacklistFactors, audiences: $audiences, scopes: $scopes, ipAddress: $ipAddress, userAgent: $userAgent, deviceId: $deviceId, nonce: $nonce, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; +} + + +} + +/// @nodoc +abstract mixin class _$SnAuthChallengeCopyWith<$Res> implements $SnAuthChallengeCopyWith<$Res> { + factory _$SnAuthChallengeCopyWith(_SnAuthChallenge value, $Res Function(_SnAuthChallenge) _then) = __$SnAuthChallengeCopyWithImpl; +@override @useResult +$Res call({ + String id, DateTime expiredAt, int stepRemain, int stepTotal, List blacklistFactors, List audiences, List scopes, String ipAddress, String userAgent, String? deviceId, String? nonce, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt +}); + + + + +} +/// @nodoc +class __$SnAuthChallengeCopyWithImpl<$Res> + implements _$SnAuthChallengeCopyWith<$Res> { + __$SnAuthChallengeCopyWithImpl(this._self, this._then); + + final _SnAuthChallenge _self; + final $Res Function(_SnAuthChallenge) _then; + +/// Create a copy of SnAuthChallenge +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? expiredAt = null,Object? stepRemain = null,Object? stepTotal = null,Object? blacklistFactors = null,Object? audiences = null,Object? scopes = null,Object? ipAddress = null,Object? userAgent = null,Object? deviceId = freezed,Object? nonce = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { + return _then(_SnAuthChallenge( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,expiredAt: null == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable +as DateTime,stepRemain: null == stepRemain ? _self.stepRemain : stepRemain // ignore: cast_nullable_to_non_nullable +as int,stepTotal: null == stepTotal ? _self.stepTotal : stepTotal // ignore: cast_nullable_to_non_nullable +as int,blacklistFactors: null == blacklistFactors ? _self._blacklistFactors : blacklistFactors // ignore: cast_nullable_to_non_nullable +as List,audiences: null == audiences ? _self._audiences : audiences // ignore: cast_nullable_to_non_nullable +as List,scopes: null == scopes ? _self._scopes : scopes // ignore: cast_nullable_to_non_nullable +as List,ipAddress: null == ipAddress ? _self.ipAddress : ipAddress // ignore: cast_nullable_to_non_nullable +as String,userAgent: null == userAgent ? _self.userAgent : userAgent // ignore: cast_nullable_to_non_nullable +as String,deviceId: freezed == deviceId ? _self.deviceId : deviceId // ignore: cast_nullable_to_non_nullable +as String?,nonce: freezed == nonce ? _self.nonce : nonce // ignore: cast_nullable_to_non_nullable +as String?,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?, + )); +} + + +} + + +/// @nodoc +mixin _$SnAuthFactor { + + int get id; int get type; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; +/// Create a copy of SnAuthFactor +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SnAuthFactorCopyWith get copyWith => _$SnAuthFactorCopyWithImpl(this as SnAuthFactor, _$identity); + + /// Serializes this SnAuthFactor to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SnAuthFactor&&(identical(other.id, id) || other.id == id)&&(identical(other.type, type) || other.type == type)&&(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,type,createdAt,updatedAt,deletedAt); + +@override +String toString() { + return 'SnAuthFactor(id: $id, type: $type, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; +} + + +} + +/// @nodoc +abstract mixin class $SnAuthFactorCopyWith<$Res> { + factory $SnAuthFactorCopyWith(SnAuthFactor value, $Res Function(SnAuthFactor) _then) = _$SnAuthFactorCopyWithImpl; +@useResult +$Res call({ + int id, int type, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt +}); + + + + +} +/// @nodoc +class _$SnAuthFactorCopyWithImpl<$Res> + implements $SnAuthFactorCopyWith<$Res> { + _$SnAuthFactorCopyWithImpl(this._self, this._then); + + final SnAuthFactor _self; + final $Res Function(SnAuthFactor) _then; + +/// Create a copy of SnAuthFactor +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? type = 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,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable +as int,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?, + )); +} + +} + + +/// @nodoc +@JsonSerializable() + +class _SnAuthFactor implements SnAuthFactor { + const _SnAuthFactor({required this.id, required this.type, required this.createdAt, required this.updatedAt, required this.deletedAt}); + factory _SnAuthFactor.fromJson(Map json) => _$SnAuthFactorFromJson(json); + +@override final int id; +@override final int type; +@override final DateTime createdAt; +@override final DateTime updatedAt; +@override final DateTime? deletedAt; + +/// Create a copy of SnAuthFactor +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$SnAuthFactorCopyWith<_SnAuthFactor> get copyWith => __$SnAuthFactorCopyWithImpl<_SnAuthFactor>(this, _$identity); + +@override +Map toJson() { + return _$SnAuthFactorToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnAuthFactor&&(identical(other.id, id) || other.id == id)&&(identical(other.type, type) || other.type == type)&&(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,type,createdAt,updatedAt,deletedAt); + +@override +String toString() { + return 'SnAuthFactor(id: $id, type: $type, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; +} + + +} + +/// @nodoc +abstract mixin class _$SnAuthFactorCopyWith<$Res> implements $SnAuthFactorCopyWith<$Res> { + factory _$SnAuthFactorCopyWith(_SnAuthFactor value, $Res Function(_SnAuthFactor) _then) = __$SnAuthFactorCopyWithImpl; +@override @useResult +$Res call({ + int id, int type, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt +}); + + + + +} +/// @nodoc +class __$SnAuthFactorCopyWithImpl<$Res> + implements _$SnAuthFactorCopyWith<$Res> { + __$SnAuthFactorCopyWithImpl(this._self, this._then); + + final _SnAuthFactor _self; + final $Res Function(_SnAuthFactor) _then; + +/// Create a copy of SnAuthFactor +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? type = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { + return _then(_SnAuthFactor( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as int,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable +as int,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?, + )); +} + + +} + +// dart format on diff --git a/lib/models/auth.g.dart b/lib/models/auth.g.dart new file mode 100644 index 0000000..4231e7f --- /dev/null +++ b/lib/models/auth.g.dart @@ -0,0 +1,84 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'auth.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_AppTokenPair _$AppTokenPairFromJson(Map json) => + _AppTokenPair( + accessToken: json['access_token'] as String, + refreshToken: json['refresh_token'] as String, + ); + +Map _$AppTokenPairToJson(_AppTokenPair instance) => + { + 'access_token': instance.accessToken, + 'refresh_token': instance.refreshToken, + }; + +_SnAuthChallenge _$SnAuthChallengeFromJson(Map json) => + _SnAuthChallenge( + id: json['id'] as String, + expiredAt: DateTime.parse(json['expired_at'] as String), + stepRemain: (json['step_remain'] as num).toInt(), + stepTotal: (json['step_total'] as num).toInt(), + blacklistFactors: + (json['blacklist_factors'] as List) + .map((e) => (e as num).toInt()) + .toList(), + audiences: + (json['audiences'] as List).map((e) => e as String).toList(), + scopes: + (json['scopes'] as List).map((e) => e as String).toList(), + ipAddress: json['ip_address'] as String, + userAgent: json['user_agent'] as String, + deviceId: json['device_id'] as String?, + nonce: json['nonce'] as String?, + 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 _$SnAuthChallengeToJson(_SnAuthChallenge instance) => + { + 'id': instance.id, + 'expired_at': instance.expiredAt.toIso8601String(), + 'step_remain': instance.stepRemain, + 'step_total': instance.stepTotal, + 'blacklist_factors': instance.blacklistFactors, + 'audiences': instance.audiences, + 'scopes': instance.scopes, + 'ip_address': instance.ipAddress, + 'user_agent': instance.userAgent, + 'device_id': instance.deviceId, + 'nonce': instance.nonce, + 'created_at': instance.createdAt.toIso8601String(), + 'updated_at': instance.updatedAt.toIso8601String(), + 'deleted_at': instance.deletedAt?.toIso8601String(), + }; + +_SnAuthFactor _$SnAuthFactorFromJson(Map json) => + _SnAuthFactor( + id: (json['id'] as num).toInt(), + type: (json['type'] as num).toInt(), + 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 _$SnAuthFactorToJson(_SnAuthFactor instance) => + { + 'id': instance.id, + 'type': instance.type, + 'created_at': instance.createdAt.toIso8601String(), + 'updated_at': instance.updatedAt.toIso8601String(), + 'deleted_at': instance.deletedAt?.toIso8601String(), + }; diff --git a/lib/pods/config.dart b/lib/pods/config.dart index 082a500..eed61c0 100644 --- a/lib/pods/config.dart +++ b/lib/pods/config.dart @@ -3,8 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:responsive_framework/responsive_framework.dart'; import 'package:shared_preferences/shared_preferences.dart'; -const kAtkStoreKey = 'nex_user_atk'; -const kRtkStoreKey = 'nex_user_rtk'; +const kTokenPairStoreKey = 'dyn_user_tk'; const kNetworkServerDefault = 'http://localhost:5071'; const kNetworkServerStoreKey = 'app_server_url'; diff --git a/lib/pods/network.dart b/lib/pods/network.dart index 35bca33..4bcad40 100644 --- a/lib/pods/network.dart +++ b/lib/pods/network.dart @@ -1,8 +1,50 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:developer'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:dio/dio.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:island/models/auth.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + import 'config.dart'; -final dioProvider = Provider((ref) { +final userAgentProvider = FutureProvider((ref) async { + final String platformInfo; + if (kIsWeb) { + final deviceInfo = await DeviceInfoPlugin().webBrowserInfo; + platformInfo = 'Web; ${deviceInfo.vendor}'; + } else if (Platform.isAndroid) { + final deviceInfo = await DeviceInfoPlugin().androidInfo; + platformInfo = + 'Android; ${deviceInfo.brand} ${deviceInfo.model}; ${deviceInfo.id}'; + } else if (Platform.isIOS) { + final deviceInfo = await DeviceInfoPlugin().iosInfo; + platformInfo = 'iOS; ${deviceInfo.model}; ${deviceInfo.name}'; + } else if (Platform.isMacOS) { + final deviceInfo = await DeviceInfoPlugin().macOsInfo; + platformInfo = 'MacOS; ${deviceInfo.model}; ${deviceInfo.hostName}'; + } else if (Platform.isWindows) { + final deviceInfo = await DeviceInfoPlugin().windowsInfo; + platformInfo = + 'Windows NT; ${deviceInfo.productName}; ${deviceInfo.computerName}'; + } else if (Platform.isLinux) { + final deviceInfo = await DeviceInfoPlugin().linuxInfo; + platformInfo = 'Linux; ${deviceInfo.prettyName}'; + } else { + platformInfo = 'Unknown'; + } + + final packageInfo = await PackageInfo.fromPlatform(); + + return 'Solian/${packageInfo.version}+${packageInfo.buildNumber} ($platformInfo)'; +}); + +final apiClientProvider = Provider((ref) { final serverUrl = ref.watch(serverUrlProvider); final dio = Dio( BaseOptions( @@ -16,5 +58,127 @@ final dioProvider = Provider((ref) { ), ); + dio.interceptors.add( + InterceptorsWrapper( + onRequest: ( + RequestOptions options, + RequestInterceptorHandler handler, + ) async { + final atk = await getFreshAtk(ref); + if (atk != null) { + options.headers['Authorization'] = 'Bearer $atk'; + } + final userAgent = ref.watch(userAgentProvider); + if (userAgent.value != null) { + options.headers['User-Agent'] = userAgent.value; + } + return handler.next(options); + }, + ), + ); + return dio; }); + +final tokenPairProvider = Provider((ref) { + final prefs = ref.watch(sharedPreferencesProvider); + final tkPairString = prefs.getString(kTokenPairStoreKey); + if (tkPairString == null) return null; + return AppTokenPair.fromJson(jsonDecode(tkPairString)); +}); + +Future<(String, String)?> refreshToken(Ref ref, String? rtk) async { + if (rtk == null) return null; + + final baseUrl = ref.watch(serverUrlProvider); + + final dio = Dio(); + dio.options.baseUrl = baseUrl; + + final resp = await dio.post( + '/auth/token', + data: {'grant_type': 'refresh_token', 'refresh_token': rtk}, + ); + + final String atk = resp.data['access_token']; + final String nRtk = resp.data['refresh_token']; + setTokenPair(ref.watch(sharedPreferencesProvider), atk, nRtk); + ref.invalidate(tokenPairProvider); + + return (atk, nRtk); +} + +Completer? _refreshCompleter; + +Future getFreshAtk(Ref ref) async { + final tkPair = ref.watch(tokenPairProvider); + var atk = tkPair?.accessToken; + var rtk = tkPair?.refreshToken; + + if (_refreshCompleter != null) { + return await _refreshCompleter!.future; + } else { + _refreshCompleter = Completer(); + } + + try { + if (atk != null) { + final atkParts = atk.split('.'); + if (atkParts.length != 3) { + throw Exception('invalid format of access token'); + } + + var rawPayload = atkParts[1].replaceAll('-', '+').replaceAll('_', '/'); + switch (rawPayload.length % 4) { + case 0: + break; + case 2: + rawPayload += '=='; + break; + case 3: + rawPayload += '='; + break; + default: + throw Exception('illegal format of access token payload'); + } + + final b64 = utf8.fuse(base64Url); + final payload = b64.decode(rawPayload); + final exp = jsonDecode(payload)['exp']; + if (exp <= DateTime.now().millisecondsSinceEpoch ~/ 1000) { + log('[Auth] Access token need refresh, doing it at ${DateTime.now()}'); + final result = await refreshToken(ref, rtk); + if (result == null) { + atk = null; + } else { + atk = result.$1; + } + } + + if (atk != null) { + _refreshCompleter!.complete(atk); + return atk; + } else { + log('[Auth] Access token refresh failed...'); + _refreshCompleter!.complete(null); + } + } + } catch (err) { + log('[Auth] Failed to authenticate user... $err'); + _refreshCompleter!.completeError(err); + } finally { + _refreshCompleter = null; + } + + return null; +} + +Future setTokenPair( + SharedPreferences prefs, + String atk, + String rtk, +) async { + final tkPair = AppTokenPair(accessToken: atk, refreshToken: rtk); + final tkPairString = jsonEncode(tkPair); + prefs.setString(kTokenPairStoreKey, tkPairString); +} diff --git a/lib/route.dart b/lib/route.dart index b5b8861..4d3fc59 100644 --- a/lib/route.dart +++ b/lib/route.dart @@ -7,5 +7,10 @@ class AppRouter extends RootStackRouter { RouteType get defaultRouteType => RouteType.adaptive(); @override - List get routes => [AutoRoute(page: ExploreRoute.page, path: '/')]; + List get routes => [ + AutoRoute(page: ExploreRoute.page, path: '/'), + AutoRoute(page: AccountRoute.page, path: '/account'), + AutoRoute(page: LoginRoute.page, path: '/auth/login'), + AutoRoute(page: CreateAccountRoute.page, path: '/auth/create-account'), + ]; } diff --git a/lib/route.gr.dart b/lib/route.gr.dart index 311383f..3fade8b 100644 --- a/lib/route.gr.dart +++ b/lib/route.gr.dart @@ -9,21 +9,72 @@ // coverage:ignore-file // ignore_for_file: no_leading_underscores_for_library_prefixes -import 'package:auto_route/auto_route.dart' as _i2; -import 'package:island/screens/explore.dart' as _i1; +import 'package:auto_route/auto_route.dart' as _i5; +import 'package:island/screens/account.dart' as _i1; +import 'package:island/screens/auth/create_account.dart' as _i2; +import 'package:island/screens/auth/login.dart' as _i4; +import 'package:island/screens/explore.dart' as _i3; /// generated route for -/// [_i1.ExploreScreen] -class ExploreRoute extends _i2.PageRouteInfo { - const ExploreRoute({List<_i2.PageRouteInfo>? children}) +/// [_i1.AccountScreen] +class AccountRoute extends _i5.PageRouteInfo { + const AccountRoute({List<_i5.PageRouteInfo>? children}) + : super(AccountRoute.name, initialChildren: children); + + static const String name = 'AccountRoute'; + + static _i5.PageInfo page = _i5.PageInfo( + name, + builder: (data) { + return const _i1.AccountScreen(); + }, + ); +} + +/// generated route for +/// [_i2.CreateAccountScreen] +class CreateAccountRoute extends _i5.PageRouteInfo { + const CreateAccountRoute({List<_i5.PageRouteInfo>? children}) + : super(CreateAccountRoute.name, initialChildren: children); + + static const String name = 'CreateAccountRoute'; + + static _i5.PageInfo page = _i5.PageInfo( + name, + builder: (data) { + return const _i2.CreateAccountScreen(); + }, + ); +} + +/// generated route for +/// [_i3.ExploreScreen] +class ExploreRoute extends _i5.PageRouteInfo { + const ExploreRoute({List<_i5.PageRouteInfo>? children}) : super(ExploreRoute.name, initialChildren: children); static const String name = 'ExploreRoute'; - static _i2.PageInfo page = _i2.PageInfo( + static _i5.PageInfo page = _i5.PageInfo( name, builder: (data) { - return const _i1.ExploreScreen(); + return const _i3.ExploreScreen(); + }, + ); +} + +/// generated route for +/// [_i4.LoginScreen] +class LoginRoute extends _i5.PageRouteInfo { + const LoginRoute({List<_i5.PageRouteInfo>? children}) + : super(LoginRoute.name, initialChildren: children); + + static const String name = 'LoginRoute'; + + static _i5.PageInfo page = _i5.PageInfo( + name, + builder: (data) { + return const _i4.LoginScreen(); }, ); } diff --git a/lib/screens/account.dart b/lib/screens/account.dart new file mode 100644 index 0000000..ba74424 --- /dev/null +++ b/lib/screens/account.dart @@ -0,0 +1,32 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:island/route.gr.dart'; +import 'package:island/widgets/app_scaffold.dart'; + +@RoutePage() +class AccountScreen extends StatelessWidget { + const AccountScreen({super.key}); + + @override + Widget build(BuildContext context) { + return AppScaffold( + appBar: AppBar(title: const Text('Account')), + body: Column( + children: [ + ListTile( + title: Text('Login'), + onTap: () { + context.router.push(LoginRoute()); + }, + ), + ListTile( + title: Text('Create an account'), + onTap: () { + context.router.push(CreateAccountRoute()); + }, + ), + ], + ), + ); + } +} diff --git a/lib/screens/auth/captcha.dart b/lib/screens/auth/captcha.dart new file mode 100644 index 0000000..3f2cb52 --- /dev/null +++ b/lib/screens/auth/captcha.dart @@ -0,0 +1 @@ +export 'captcha.native.dart' if (dart.library.html) 'captcha.web.dart'; diff --git a/lib/screens/auth/captcha.native.dart b/lib/screens/auth/captcha.native.dart new file mode 100644 index 0000000..7dac1a1 --- /dev/null +++ b/lib/screens/auth/captcha.native.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:island/pods/config.dart'; +import 'package:island/widgets/app_scaffold.dart'; + +class CaptchaScreen extends ConsumerWidget { + const CaptchaScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final serverUrl = ref.watch(serverUrlProvider); + + return AppScaffold( + appBar: AppBar(title: Text("Anti-Robot")), + body: InAppWebView( + initialUrlRequest: URLRequest( + url: WebUri('$serverUrl/auth/captcha?redirect_uri=solink://captcha'), + ), + shouldOverrideUrlLoading: (controller, navigationAction) async { + Uri? url = navigationAction.request.url; + if (url != null && url.queryParameters.containsKey('captcha_tk')) { + Navigator.pop(context, url.queryParameters['captcha_tk']!); + return NavigationActionPolicy.CANCEL; + } + return NavigationActionPolicy.ALLOW; + }, + ), + ); + } +} diff --git a/lib/screens/auth/captcha.web.dart b/lib/screens/auth/captcha.web.dart new file mode 100644 index 0000000..85090dd --- /dev/null +++ b/lib/screens/auth/captcha.web.dart @@ -0,0 +1,50 @@ +import 'dart:ui_web' as ui; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:island/pods/config.dart'; +import 'package:island/widgets/app_scaffold.dart'; +import 'package:web/web.dart' as web; +import 'package:flutter/material.dart'; + +class CaptchaScreen extends HookConsumerWidget { + const CaptchaScreen({super.key}); + + void _setupWebListener(BuildContext context, String serverUrl) { + web.window.onMessage.listen((event) { + if (event.data != null && event.data is String) { + final message = event.data as String; + if (message.startsWith("captcha_tk=")) { + String token = message.replaceFirst("captcha_tk=", ""); + Navigator.pop(context, token); + } + } + }); + + final iframe = + web.HTMLIFrameElement() + ..src = '$serverUrl/captcha' + ..style.border = 'none' + ..width = '100%' + ..height = '100%'; + + web.document.body!.append(iframe); + ui.platformViewRegistry.registerViewFactory( + 'captcha-iframe', + (int viewId) => iframe, + ); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + useCallback(() { + print('use callback runs once'); + final serverUrl = ref.watch(serverUrlProvider); + _setupWebListener(context, serverUrl); + }, []); + + return AppScaffold( + appBar: AppBar(title: Text("Anti-Robot")), + body: HtmlElementView(viewType: 'captcha-iframe'), + ); + } +} diff --git a/lib/screens/auth/create_account.dart b/lib/screens/auth/create_account.dart new file mode 100644 index 0000000..bac4521 --- /dev/null +++ b/lib/screens/auth/create_account.dart @@ -0,0 +1,265 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:email_validator/email_validator.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:gap/gap.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +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:styled_widget/styled_widget.dart'; +import 'package:url_launcher/url_launcher_string.dart'; + +import 'captcha.dart'; + +@RoutePage() +class CreateAccountScreen extends HookConsumerWidget { + const CreateAccountScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final formKey = useMemoized(GlobalKey.new, const []); + + final email = useState(''); + final username = useState(''); + final nickname = useState(''); + final password = useState(''); + + void performAction() async { + if (!formKey.currentState!.validate()) return; + + if (email.value.isEmpty || + username.value.isEmpty || + nickname.value.isEmpty || + password.value.isEmpty) { + return; + } + + final captchaTk = await Navigator.of( + context, + ).push(MaterialPageRoute(builder: (context) => CaptchaScreen())); + if (captchaTk == null) return; + + if (!context.mounted) return; + + try { + final client = ref.watch(apiClientProvider); + await client.post( + '/users', + data: { + 'name': username.value, + 'nick': nickname.value, + 'email': email.value, + 'password': password.value, + 'language': EasyLocalization.of(context)!.currentLocale.toString(), + 'captcha_token': captchaTk, + }, + ); + + if (!context.mounted) return; + context.router.replace(CreateAccountRoute()); + } catch (err) { + showErrorAlert(err); + } + } + + return AppScaffold( + appBar: AppBar( + leading: const PageBackButton(), + title: Text('createAccount').tr(), + ), + body: + StyledWidget( + Container( + constraints: const BoxConstraints(maxWidth: 380), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Align( + alignment: Alignment.centerLeft, + child: CircleAvatar( + radius: 26, + child: Icon(MdiIcons.accountPlus, size: 28), + ).padding(bottom: 8), + ), + Text( + 'createAccount', + style: const TextStyle( + fontSize: 28, + fontWeight: FontWeight.w900, + ), + ).tr().padding(left: 4, bottom: 16), + Form( + key: formKey, + autovalidateMode: AutovalidateMode.onUserInteraction, + child: Column( + children: [ + TextFormField( + initialValue: username.value, + validator: (value) { + if (value == null || value.isEmpty) { + return 'fieldCannotBeEmpty'.tr(); + } + return null; + }, + autocorrect: false, + enableSuggestions: false, + onSaved: (val) => username.value = val ?? '', + autofillHints: const [AutofillHints.username], + decoration: InputDecoration( + isDense: true, + border: const UnderlineInputBorder(), + labelText: 'username'.tr(), + ), + onTapOutside: + (_) => + FocusManager.instance.primaryFocus + ?.unfocus(), + ), + const Gap(12), + TextFormField( + initialValue: nickname.value, + validator: (value) { + if (value == null || value.isEmpty) { + return 'fieldCannotBeEmpty'.tr(); + } + return null; + }, + autocorrect: false, + enableSuggestions: false, + onSaved: (val) => nickname.value = val ?? '', + autofillHints: const [AutofillHints.nickname], + decoration: InputDecoration( + isDense: true, + border: const UnderlineInputBorder(), + labelText: 'nickname'.tr(), + ), + onTapOutside: + (_) => + FocusManager.instance.primaryFocus + ?.unfocus(), + ), + const Gap(12), + TextFormField( + initialValue: email.value, + validator: (value) { + if (value == null || value.isEmpty) { + return 'fieldCannotBeEmpty'.tr(); + } + if (!EmailValidator.validate(value)) { + return 'fieldEmailAddressMustBeValid'.tr(); + } + return null; + }, + autocorrect: false, + enableSuggestions: false, + onSaved: (val) => email.value = val ?? '', + autofillHints: const [AutofillHints.email], + decoration: InputDecoration( + isDense: true, + border: const UnderlineInputBorder(), + labelText: 'email'.tr(), + ), + onTapOutside: + (_) => + FocusManager.instance.primaryFocus + ?.unfocus(), + ), + const Gap(12), + TextFormField( + initialValue: password.value, + validator: (value) { + if (value == null || value.isEmpty) { + return 'fieldCannotBeEmpty'.tr(); + } + return null; + }, + obscureText: true, + autocorrect: false, + enableSuggestions: false, + onSaved: (val) => password.value = val ?? '', + autofillHints: const [AutofillHints.password], + decoration: InputDecoration( + isDense: true, + border: const UnderlineInputBorder(), + labelText: 'password'.tr(), + ), + onTapOutside: + (_) => + FocusManager.instance.primaryFocus + ?.unfocus(), + ), + ], + ).padding(horizontal: 7), + ), + const Gap(16), + Align( + alignment: Alignment.centerRight, + child: StyledWidget( + Container( + constraints: const BoxConstraints(maxWidth: 290), + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + 'termAcceptNextWithAgree'.tr(), + textAlign: TextAlign.end, + style: Theme.of( + context, + ).textTheme.bodySmall!.copyWith( + color: Theme.of(context).colorScheme.onSurface + .withAlpha((255 * 0.75).round()), + ), + ), + Material( + color: Colors.transparent, + child: InkWell( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text('termAcceptLink').tr(), + const Gap(4), + Icon(MdiIcons.launch, size: 14), + ], + ), + onTap: () { + launchUrlString( + 'https://solsynth.dev/terms', + ); + }, + ), + ), + ], + ), + ), + ).padding(horizontal: 16), + ), + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: () { + if (formKey.currentState?.validate() ?? false) { + performAction(); + } + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text("next").tr(), + Icon(MdiIcons.chevronRight), + ], + ), + ), + ), + ], + ), + ), + ), + ).padding(all: 24).center(), + ); + } +} diff --git a/lib/screens/auth/login.dart b/lib/screens/auth/login.dart new file mode 100644 index 0000000..1728bce --- /dev/null +++ b/lib/screens/auth/login.dart @@ -0,0 +1,459 @@ +import 'package:animations/animations.dart'; +import 'package:auto_route/auto_route.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +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/widgets/alert.dart'; +import 'package:island/widgets/app_scaffold.dart'; +import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; +import 'package:styled_widget/styled_widget.dart'; +import 'package:url_launcher/url_launcher_string.dart'; + +@RoutePage() +class LoginScreen extends HookConsumerWidget { + const LoginScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final period = useState(0); + final currentTicket = useState(null); + final factors = useState>([]); + final factorPicked = useState(null); + return AppScaffold( + appBar: AppBar( + leading: const PageBackButton(), + title: Text('login').tr(), + ), + body: Theme( + data: Theme.of(context).copyWith(canvasColor: Colors.transparent), + child: + SingleChildScrollView( + child: PageTransitionSwitcher( + transitionBuilder: ( + Widget child, + Animation primaryAnimation, + Animation secondaryAnimation, + ) { + return SharedAxisTransition( + animation: primaryAnimation, + secondaryAnimation: secondaryAnimation, + transitionType: SharedAxisTransitionType.horizontal, + child: Container( + constraints: BoxConstraints(maxWidth: 380), + child: child, + ), + ); + }, + child: switch (period.value % 3) { + 1 => _LoginPickerScreen( + key: const ValueKey(1), + ticket: currentTicket.value, + factors: factors.value, + onChallenge: + (SnAuthChallenge? p0) => currentTicket.value = p0, + onPickFactor: (SnAuthFactor p0) => factorPicked.value = p0, + onNext: () => period.value++, + ), + 2 => _LoginCheckScreen( + key: const ValueKey(2), + challenge: currentTicket.value, + factor: factorPicked.value, + onChallenge: + (SnAuthChallenge? p0) => currentTicket.value = p0, + onNext: () => period.value++, + ), + _ => _LoginLookupScreen( + key: const ValueKey(0), + ticket: currentTicket.value, + onChallenge: + (SnAuthChallenge? p0) => currentTicket.value = p0, + onFactor: + (List? p0) => factors.value = p0 ?? [], + onNext: () => period.value++, + ), + }, + ).padding(all: 24), + ).center(), + ), + ); + } +} + +class _LoginCheckScreen extends HookConsumerWidget { + final SnAuthChallenge? challenge; + final SnAuthFactor? factor; + final Function(SnAuthChallenge?) onChallenge; + final Function onNext; + + const _LoginCheckScreen({ + super.key, + required this.challenge, + required this.factor, + required this.onChallenge, + required this.onNext, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isBusy = useState(false); + final passwordController = useTextEditingController(); + + Future performCheckTicket() async { + final pwd = passwordController.value.text; + if (pwd.isEmpty) return; + isBusy.value = true; + try { + final client = ref.watch(apiClientProvider); + final resp = await client.patch( + '/auth/challenge/${challenge!.id}', + data: {'factor_id': factor!.id, 'password': pwd}, + ); + final result = SnAuthChallenge.fromJson(resp.data); + onChallenge(result); + if (result.stepRemain > 0) { + onNext(); + return; + } + final tokenResp = await client.post( + '/auth/token', + data: {'grant_type': 'authorization_code', 'code': result.id}, + ); + final atk = tokenResp.data['access_token']; + final rtk = tokenResp.data['refresh_token']; + setTokenPair(ref.watch(sharedPreferencesProvider), atk, rtk); + ref.invalidate(tokenPairProvider); + if (!context.mounted) return; + // TODO userinfo + Navigator.pop(context, true); + } catch (err) { + showErrorAlert(err); + return; + } finally { + isBusy.value = false; + } + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Align( + alignment: Alignment.centerLeft, + child: CircleAvatar( + radius: 26, + child: Icon(MdiIcons.formTextboxPassword, size: 28), + ).padding(bottom: 8), + ), + Text( + 'loginEnterPassword'.tr(), + style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w900), + ).padding(left: 4, bottom: 16), + TextField( + autocorrect: false, + enableSuggestions: false, + controller: passwordController, + obscureText: true, + autofillHints: [ + factor!.type == 0 + ? AutofillHints.password + : AutofillHints.oneTimeCode, + ], + decoration: InputDecoration( + isDense: true, + border: const UnderlineInputBorder(), + labelText: 'password'.tr(), + ), + onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), + onSubmitted: isBusy.value ? null : (_) => performCheckTicket(), + ).padding(horizontal: 7), + const Gap(12), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: isBusy.value ? null : () => performCheckTicket(), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [Text('next').tr(), Icon(MdiIcons.chevronRight)], + ), + ), + ], + ), + ], + ); + } +} + +class _LoginPickerScreen extends HookConsumerWidget { + final SnAuthChallenge? ticket; + final List? factors; + final Function(SnAuthChallenge?) onChallenge; + final Function(SnAuthFactor) onPickFactor; + final Function onNext; + + const _LoginPickerScreen({ + super.key, + required this.ticket, + required this.factors, + required this.onChallenge, + required this.onPickFactor, + required this.onNext, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isBusy = useState(false); + final factorPicked = useState(null); + + final unfocusColor = Theme.of( + context, + ).colorScheme.onSurface.withAlpha((255 * 0.75).round()); + + void performGetFactorCode() async { + if (factorPicked.value == null) return; + + isBusy.value = true; + final client = ref.watch(apiClientProvider); + + try { + // Request one-time-password code + await client.post( + '/auth/challenge/${ticket!.id}/factors/${factorPicked.value}', + ); + onPickFactor(factors!.where((x) => x.id == factorPicked.value).first); + onNext(); + } catch (err) { + showErrorAlert(err); + return; + } finally { + isBusy.value = false; + } + } + + return Column( + key: const ValueKey(1), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Align( + alignment: Alignment.centerLeft, + child: CircleAvatar( + radius: 26, + child: Icon(MdiIcons.security, size: 28), + ).padding(bottom: 8), + ), + Text( + 'loginPickFactor', + style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w900), + ).tr().padding(left: 4), + const Gap(8), + Card( + margin: const EdgeInsets.symmetric(vertical: 4), + child: Column( + children: + factors + ?.map( + (x) => CheckboxListTile( + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(8)), + ), + secondary: Icon(MdiIcons.fileQuestion), + title: Text('unknown').tr(), + enabled: !ticket!.blacklistFactors.contains(x.id), + value: factorPicked.value == x.id, + onChanged: (value) { + if (value == true) { + factorPicked.value = x.id; + } + }, + ), + ) + .toList() ?? + List.empty(), + ), + ), + const Gap(8), + Text( + 'loginMultiFactor'.plural(ticket!.stepRemain), + style: TextStyle(color: unfocusColor, fontSize: 13), + ).padding(horizontal: 16), + const Gap(12), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: isBusy.value ? null : () => performGetFactorCode(), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [Text('next'.tr()), Icon(MdiIcons.chevronRight)], + ), + ), + ], + ), + ], + ); + } +} + +class _LoginLookupScreen extends HookConsumerWidget { + final SnAuthChallenge? ticket; + final Function(SnAuthChallenge?) onChallenge; + final Function(List?) onFactor; + final Function onNext; + + const _LoginLookupScreen({ + super.key, + required this.ticket, + required this.onChallenge, + required this.onFactor, + required this.onNext, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isBusy = useState(false); + final usernameController = useTextEditingController(); + + Future requestResetPassword() async { + final uname = usernameController.value.text; + if (uname.isEmpty) { + showErrorAlert('loginResetPasswordHint'.tr()); + return; + } + isBusy.value = true; + try { + final client = ref.watch(apiClientProvider); + final lookupResp = await client.get('/users/lookup?probe=$uname'); + await client.post( + '/users/me/password-reset', + data: {'user_id': lookupResp.data['id']}, + ); + showInfoAlert('done'.tr(), 'signinResetPasswordSent'.tr()); + } catch (err) { + showErrorAlert(err); + } finally { + isBusy.value = false; + } + } + + Future performNewTicket() async { + final uname = usernameController.value.text; + if (uname.isEmpty) return; + isBusy.value = true; + try { + final client = ref.watch(apiClientProvider); + final resp = await client.post( + '/auth/challenge', + data: {'account': uname}, + ); + final result = SnAuthChallenge.fromJson(resp.data); + onChallenge(result); + final factorResp = await client.get( + '/auth/challenge/${result.id}/factors', + ); + onFactor( + List.from( + factorResp.data.map((ele) => SnAuthFactor.fromJson(ele)), + ), + ); + onNext(); + } catch (err) { + showErrorAlert(err); + return; + } finally { + isBusy.value = false; + } + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Align( + alignment: Alignment.centerLeft, + child: CircleAvatar( + radius: 26, + child: Icon(MdiIcons.login, size: 28), + ).padding(bottom: 8), + ), + Text( + 'loginGreeting', + style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w900), + ).tr().padding(left: 4, bottom: 16), + TextField( + autocorrect: false, + enableSuggestions: false, + controller: usernameController, + autofillHints: const [AutofillHints.username], + decoration: InputDecoration( + isDense: true, + border: const UnderlineInputBorder(), + labelText: 'username'.tr(), + helperText: 'usernameLookupHint'.tr(), + ), + onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), + onSubmitted: isBusy.value ? null : (_) => performNewTicket(), + ).padding(horizontal: 7), + const Gap(12), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + TextButton( + onPressed: isBusy.value ? null : () => requestResetPassword(), + style: TextButton.styleFrom(foregroundColor: Colors.grey), + child: Text('forgotPassword'.tr()), + ), + TextButton( + onPressed: isBusy.value ? null : () => performNewTicket(), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [Text('next').tr(), Icon(MdiIcons.chevronRight)], + ), + ), + ], + ), + const Gap(12), + Align( + alignment: Alignment.centerRight, + child: StyledWidget( + Container( + constraints: const BoxConstraints(maxWidth: 290), + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + 'termAcceptNextWithAgree'.tr(), + textAlign: TextAlign.end, + style: Theme.of(context).textTheme.bodySmall!.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurface.withAlpha((255 * 0.75).round()), + ), + ), + Material( + color: Colors.transparent, + child: InkWell( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text('termAcceptLink'.tr()), + const Gap(4), + Icon(MdiIcons.launch, size: 14), + ], + ), + onTap: () { + launchUrlString('https://solsynth.dev/terms'); + }, + ), + ), + ], + ), + ), + ).padding(horizontal: 16), + ), + ], + ); + } +} diff --git a/lib/screens/explore.dart b/lib/screens/explore.dart index ad83676..e1a0793 100644 --- a/lib/screens/explore.dart +++ b/lib/screens/explore.dart @@ -40,8 +40,8 @@ class ExploreScreen extends ConsumerWidget { } final postListProvider = FutureProvider<_PostListController>((ref) async { - final dio = ref.watch(dioProvider); - final controller = _PostListController(dio); + final client = ref.watch(apiClientProvider); + final controller = _PostListController(client); await controller.fetchMore(); return controller; }); diff --git a/lib/widgets/alert.dart b/lib/widgets/alert.dart index 9d29652..ba61ddf 100644 --- a/lib/widgets/alert.dart +++ b/lib/widgets/alert.dart @@ -8,3 +8,12 @@ void showErrorAlert(dynamic err) async { iconStyle: IconStyle.error, ); } + +void showInfoAlert(String message, String title) async { + FlutterPlatformAlert.showAlert( + windowTitle: title, + text: message, + alertStyle: AlertButtonStyle.ok, + iconStyle: IconStyle.information, + ); +} diff --git a/lib/widgets/app_scaffold.dart b/lib/widgets/app_scaffold.dart index 8b652f3..d5dfa6d 100644 --- a/lib/widgets/app_scaffold.dart +++ b/lib/widgets/app_scaffold.dart @@ -4,6 +4,9 @@ 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:island/route.dart'; +import 'package:island/route.gr.dart'; +import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; import 'package:path_provider/path_provider.dart'; import 'package:responsive_framework/responsive_framework.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -11,7 +14,8 @@ import 'package:styled_widget/styled_widget.dart'; class WindowScaffold extends StatelessWidget { final Widget child; - const WindowScaffold({super.key, required this.child}); + final AppRouter router; + const WindowScaffold({super.key, required this.child, required this.router}); @override Widget build(BuildContext context) { @@ -75,7 +79,29 @@ class WindowScaffold extends StatelessWidget { ), ); } - return child; + 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; + } + }, + ), + ); } } diff --git a/lib/widgets/content/video.web.dart b/lib/widgets/content/video.web.dart index 6cf4233..78cfd1c 100644 --- a/lib/widgets/content/video.web.dart +++ b/lib/widgets/content/video.web.dart @@ -3,7 +3,12 @@ import 'package:flutter/material.dart'; class UniversalVideo extends StatelessWidget { final String uri; - const UniversalVideo({super.key, required this.uri}); + final double aspectRatio; + const UniversalVideo({ + super.key, + required this.uri, + required this.aspectRatio, + }); @override Widget build(BuildContext context) { diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index ca4aec5..eb32abd 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,6 +6,8 @@ import FlutterMacOS import Foundation import bitsdojo_window_macos +import device_info_plus +import flutter_inappwebview_macos import flutter_platform_alert import media_kit_libs_macos_video import media_kit_video @@ -19,6 +21,8 @@ import wakelock_plus func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { BitsdojoWindowPlugin.register(with: registry.registrar(forPlugin: "BitsdojoWindowPlugin")) + DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) + InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin")) FlutterPlatformAlertPlugin.register(with: registry.registrar(forPlugin: "FlutterPlatformAlertPlugin")) MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin")) MediaKitVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitVideoPlugin")) diff --git a/pubspec.lock b/pubspec.lock index 7af5626..f13f949 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -17,6 +17,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.4.1" + animations: + dependency: "direct main" + description: + name: animations + sha256: d3d6dcfb218225bbe68e87ccf6378bbb2e32a94900722c5f81611dad089911cb + url: "https://pub.dev" + source: hosted + version: "2.0.11" archive: dependency: transitive description: @@ -273,6 +281,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.11" + device_info_plus: + dependency: "direct main" + description: + name: device_info_plus + sha256: "0c6396126421b590089447154c5f98a5de423b70cfb15b1578fd018843ee6f53" + url: "https://pub.dev" + source: hosted + version: "11.4.0" + device_info_plus_platform_interface: + dependency: transitive + description: + name: device_info_plus_platform_interface + sha256: "0b04e02b30791224b31969eb1b50d723498f402971bff3630bca2ba839bd1ed2" + url: "https://pub.dev" + source: hosted + version: "7.0.2" dio: dependency: "direct main" description: @@ -289,6 +313,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.1" + easy_localization: + dependency: "direct main" + description: + name: easy_localization + sha256: "0f5239c7b8ab06c66440cfb0e9aa4b4640429c6668d5a42fe389c5de42220b12" + url: "https://pub.dev" + source: hosted + version: "3.0.7+1" + easy_logger: + dependency: transitive + description: + name: easy_logger + sha256: c764a6e024846f33405a2342caf91c62e357c24b02c04dbc712ef232bf30ffb7 + url: "https://pub.dev" + source: hosted + version: "0.0.2" + email_validator: + dependency: "direct main" + description: + name: email_validator + sha256: b19aa5d92fdd76fbc65112060c94d45ba855105a28bb6e462de7ff03b12fa1fb + url: "https://pub.dev" + source: hosted + version: "3.0.0" fake_async: dependency: transitive description: @@ -358,6 +406,70 @@ packages: url: "https://pub.dev" source: hosted version: "0.21.2" + flutter_inappwebview: + dependency: "direct main" + description: + name: flutter_inappwebview + sha256: "80092d13d3e29b6227e25b67973c67c7210bd5e35c4b747ca908e31eb71a46d5" + url: "https://pub.dev" + source: hosted + version: "6.1.5" + flutter_inappwebview_android: + dependency: transitive + description: + name: flutter_inappwebview_android + sha256: "62557c15a5c2db5d195cb3892aab74fcaec266d7b86d59a6f0027abd672cddba" + url: "https://pub.dev" + source: hosted + version: "1.1.3" + flutter_inappwebview_internal_annotations: + dependency: transitive + description: + name: flutter_inappwebview_internal_annotations + sha256: "787171d43f8af67864740b6f04166c13190aa74a1468a1f1f1e9ee5b90c359cd" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + flutter_inappwebview_ios: + dependency: transitive + description: + name: flutter_inappwebview_ios + sha256: "5818cf9b26cf0cbb0f62ff50772217d41ea8d3d9cc00279c45f8aabaa1b4025d" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_inappwebview_macos: + dependency: transitive + description: + name: flutter_inappwebview_macos + sha256: c1fbb86af1a3738e3541364d7d1866315ffb0468a1a77e34198c9be571287da1 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_inappwebview_platform_interface: + dependency: transitive + description: + name: flutter_inappwebview_platform_interface + sha256: cf5323e194096b6ede7a1ca808c3e0a078e4b33cc3f6338977d75b4024ba2500 + url: "https://pub.dev" + source: hosted + version: "1.3.0+1" + flutter_inappwebview_web: + dependency: transitive + description: + name: flutter_inappwebview_web + sha256: "55f89c83b0a0d3b7893306b3bb545ba4770a4df018204917148ebb42dc14a598" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_inappwebview_windows: + dependency: transitive + description: + name: flutter_inappwebview_windows + sha256: "8b4d3a46078a2cdc636c4a3d10d10f2a16882f6be607962dbfff8874d1642055" + url: "https://pub.dev" + source: hosted + version: "0.6.0" flutter_lints: dependency: "direct dev" description: @@ -366,6 +478,11 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.0" + flutter_localizations: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_markdown: dependency: "direct main" description: @@ -528,6 +645,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.5.4" + intl: + dependency: transitive + description: + name: intl + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + url: "https://pub.dev" + source: hosted + version: "0.19.0" io: dependency: transitive description: @@ -745,7 +870,7 @@ packages: source: hosted version: "2.2.0" package_info_plus: - dependency: transitive + dependency: "direct main" description: name: package_info_plus sha256: "7976bfe4c583170d6cdc7077e3237560b364149fcd268b5f53d95a991963b191" @@ -1381,6 +1506,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.12.0" + win32_registry: + dependency: transitive + description: + name: win32_registry + sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae" + url: "https://pub.dev" + source: hosted + version: "2.1.0" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 32ca5c6..049df38 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: island description: "The Solar Network V3 App" # The following line prevents the package from being accidentally published to # pub.dev using `flutter pub publish`. This is preferred for private packages. -publish_to: 'none' # Remove this line if you wish to publish to pub.dev +publish_to: "none" # Remove this line if you wish to publish to pub.dev # The following defines the version and build number for your application. # A version number is three numbers separated by dots, like 1.2.43 @@ -64,6 +64,12 @@ dependencies: 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 dev_dependencies: flutter_test: @@ -85,16 +91,14 @@ dev_dependencies: # The following section is specific to Flutter packages. flutter: - # The following line ensures that the Material Icons font is # included with your application, so that you can use the icons in # the material Icons class. uses-material-design: true # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg + assets: + - assets/i18n/ # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/to/resolution-aware-images diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 2e98137..a47c644 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,6 +7,7 @@ #include "generated_plugin_registrant.h" #include +#include #include #include #include @@ -16,6 +17,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { BitsdojoWindowPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("BitsdojoWindowPlugin")); + FlutterInappwebviewWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterInappwebviewWindowsPluginCApi")); FlutterPlatformAlertPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterPlatformAlertPlugin")); MediaKitLibsWindowsVideoPluginCApiRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 39b5d23..90fdd8d 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST bitsdojo_window_windows + flutter_inappwebview_windows flutter_platform_alert media_kit_libs_windows_video media_kit_video