Created project base structure, state management, router and login

This commit is contained in:
Juan Gilsanz Polo
2024-02-21 23:53:22 +01:00
commit 460e13b2e9
168 changed files with 6966 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
class SharedPreferencesKeys {
static const serversInstances = "serversInstances";
}

78
lib/config/theme.dart Normal file
View File

@@ -0,0 +1,78 @@
import 'package:flutter/material.dart';
ThemeData lightTheme(ColorScheme? dynamicColorScheme) => ThemeData(
useMaterial3: true,
colorScheme: dynamicColorScheme,
snackBarTheme: SnackBarThemeData(
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5),
),
elevation: 4,
),
brightness: Brightness.light,
listTileTheme: ListTileThemeData(
tileColor: Colors.transparent,
textColor: dynamicColorScheme != null
? dynamicColorScheme.onSurfaceVariant
: const Color.fromRGBO(117, 117, 117, 1),
iconColor: dynamicColorScheme != null
? dynamicColorScheme.onSurfaceVariant
: const Color.fromRGBO(117, 117, 117, 1),
),
);
ThemeData darkTheme(ColorScheme? dynamicColorScheme) => ThemeData(
useMaterial3: true,
colorScheme: dynamicColorScheme,
snackBarTheme: SnackBarThemeData(
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5),
),
elevation: 4,
),
brightness: Brightness.dark,
listTileTheme: ListTileThemeData(
tileColor: Colors.transparent,
textColor: dynamicColorScheme != null
? dynamicColorScheme.onSurfaceVariant
: const Color.fromRGBO(187, 187, 187, 1),
iconColor: dynamicColorScheme != null
? dynamicColorScheme.onSurfaceVariant
: const Color.fromRGBO(187, 187, 187, 1),
),
);
ThemeData lightThemeOldVersions(MaterialColor primaryColor) => ThemeData(
useMaterial3: true,
colorSchemeSeed: primaryColor,
snackBarTheme: SnackBarThemeData(
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
elevation: 4,
),
listTileTheme: const ListTileThemeData(
tileColor: Colors.transparent,
textColor: Color.fromRGBO(117, 117, 117, 1),
iconColor: Color.fromRGBO(117, 117, 117, 1),
),
brightness: Brightness.light,
);
ThemeData darkThemeOldVersions(MaterialColor primaryColor) => ThemeData(
useMaterial3: true,
colorSchemeSeed: primaryColor,
snackBarTheme: SnackBarThemeData(
contentTextStyle: const TextStyle(color: Colors.white),
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
elevation: 4,
),
listTileTheme: const ListTileThemeData(
tileColor: Colors.transparent,
textColor: Color.fromRGBO(187, 187, 187, 1),
iconColor: Color.fromRGBO(187, 187, 187, 1),
),
brightness: Brightness.dark,
);

18
lib/constants/colors.dart Normal file
View File

@@ -0,0 +1,18 @@
import 'package:flutter/material.dart';
import 'package:my_linkding/constants/linkding_color.dart';
const List<MaterialColor> colors = [
linkdingColor,
Colors.red,
Colors.green,
Colors.blue,
Colors.yellow,
Colors.orange,
Colors.brown,
Colors.cyan,
Colors.purple,
Colors.pink,
Colors.deepOrange,
Colors.indigo,
];

3
lib/constants/enums.dart Normal file
View File

@@ -0,0 +1,3 @@
enum LoadStatus { loading, loaded, error }
enum ConnectionMethod { http, https }

View File

@@ -0,0 +1,3 @@
import 'package:flutter/material.dart';
final scaffoldMessengerGlobalKey = GlobalKey<ScaffoldMessengerState>();

View File

@@ -0,0 +1,16 @@
import 'package:flutter/material.dart';
const Map<int, Color> swatch = {
50: Color.fromRGBO(87, 85, 217, .1),
100: Color.fromRGBO(87, 85, 217, .2),
200: Color.fromRGBO(87, 85, 217, .3),
300: Color.fromRGBO(87, 85, 217, .4),
400: Color.fromRGBO(87, 85, 217, .5),
500: Color.fromRGBO(87, 85, 217, .6),
600: Color.fromRGBO(87, 85, 217, .7),
700: Color.fromRGBO(87, 85, 217, .8),
800: Color.fromRGBO(87, 85, 217, .9),
900: Color.fromRGBO(87, 85, 217, 1),
};
const MaterialColor linkdingColor = MaterialColor(0xff5755d9, swatch);

View File

@@ -0,0 +1,6 @@
class Regexps {
static final url =
RegExp(r'/https?:\/\/(?:www\.)?([-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b)*(\/[\/\d\w\.-]*)*(?:[\?])*(.+)*/gi');
static final ipAddress = RegExp(r'^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)(\.(?!$)|$)){4}$');
static final domain = RegExp(r'^(([a-z0-9|-]+\.)*[a-z0-9|-]+\.[a-z]+)$');
}

View File

@@ -0,0 +1,4 @@
class Wrapped<T> {
final T value;
const Wrapped.value(this.value);
}

50
lib/main.dart Normal file
View File

@@ -0,0 +1,50 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:my_linkding/constants/global_keys.dart';
import 'package:dynamic_color/dynamic_color.dart';
import 'package:my_linkding/config/theme.dart';
import 'package:my_linkding/constants/colors.dart';
import 'package:my_linkding/providers/router_provider.dart';
import 'package:my_linkding/providers/shared_preferences_provider.dart';
import 'package:my_linkding/utils/http_overrides.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
HttpOverrides.global = MyHttpOverrides();
final sharedPreferences = await SharedPreferences.getInstance();
runApp(
ProviderScope(
overrides: [
sharedPreferencesProvider.overrideWithValue(sharedPreferences),
],
child: const MyApp(),
),
);
}
class MyApp extends ConsumerWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return DynamicColorBuilder(
builder: (lightDynamic, darkDynamic) => MaterialApp.router(
title: 'My Linkding',
theme: lightDynamic != null ? lightTheme(lightDynamic) : lightThemeOldVersions(colors[0]),
darkTheme: darkDynamic != null ? darkTheme(darkDynamic) : darkThemeOldVersions(colors[0]),
debugShowCheckedModeBanner: false,
scaffoldMessengerKey: scaffoldMessengerGlobalKey,
routerConfig: ref.watch(routerProvider),
),
);
}
}

View File

@@ -0,0 +1,11 @@
class ApiResponse {
final bool successful;
final dynamic content;
final int? statusCode;
const ApiResponse({
required this.successful,
this.content,
this.statusCode,
});
}

View File

@@ -0,0 +1,12 @@
import 'package:my_linkding/models/server_instance.dart';
import 'package:shared_preferences/shared_preferences.dart';
class AppStatus {
AppStatus();
AppStatus copyWith({
SharedPreferences? sharedPreferences,
ServerInstance? serverInstance,
}) =>
AppStatus();
}

View File

@@ -0,0 +1,35 @@
class ServerInstance {
final String id;
final String method;
final String ipDomain;
final int? port;
final String? path;
final String token;
const ServerInstance({
required this.id,
required this.method,
required this.ipDomain,
this.port,
this.path,
required this.token,
});
factory ServerInstance.fromJson(Map<String, dynamic> data) => ServerInstance(
id: data['id'],
method: data['method'],
ipDomain: data['ipDomain'],
port: data['port'],
path: data['path'],
token: data['token'],
);
Map<String, dynamic> toJson() => {
"id": id,
"method": method,
"ipDomain": ipDomain,
"port": port,
"path": path,
"token": token,
};
}

View File

@@ -0,0 +1,21 @@
import 'package:my_linkding/providers/server_instances_provider.dart';
import 'package:my_linkding/services/api_client.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'api_client_provider.g.dart';
@Riverpod(keepAlive: true)
class ApiClientProvider extends _$ApiClientProvider {
@override
ApiClient? build() {
final savedInstances = ref.watch(serverInstancesProvider);
if (savedInstances.isNotEmpty) {
return ApiClient(serverInstance: savedInstances[0]);
}
return null;
}
void setApiClient(ApiClient client) {
state = client;
}
}

View File

@@ -0,0 +1,26 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'api_client_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$apiClientProviderHash() => r'eff6aeacbfe369f2204d77f4884cc9d78ecf1e27';
/// See also [ApiClientProvider].
@ProviderFor(ApiClientProvider)
final apiClientProviderProvider =
NotifierProvider<ApiClientProvider, ApiClient?>.internal(
ApiClientProvider.new,
name: r'apiClientProviderProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$apiClientProviderHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$ApiClientProvider = Notifier<ApiClient?>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member

View File

@@ -0,0 +1,18 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:my_linkding/models/app_status.dart';
import 'package:shared_preferences/shared_preferences.dart';
part 'app_status_provider.g.dart';
@Riverpod(keepAlive: true)
class AppStatusProvider extends _$AppStatusProvider {
@override
AppStatus build() {
return AppStatus();
}
void setSharedPreferencesInstance(SharedPreferences instance) {
state = state.copyWith(sharedPreferences: instance);
}
}

View File

@@ -0,0 +1,26 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'app_status_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$appStatusProviderHash() => r'ba585a0cf3cf46f88f5895eacec3e992bf0cd42f';
/// See also [AppStatusProvider].
@ProviderFor(AppStatusProvider)
final appStatusProviderProvider =
NotifierProvider<AppStatusProvider, AppStatus>.internal(
AppStatusProvider.new,
name: r'appStatusProviderProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$appStatusProviderHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$AppStatusProvider = Notifier<AppStatus>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member

View File

@@ -0,0 +1,23 @@
import 'package:go_router/go_router.dart';
import 'package:my_linkding/providers/api_client_provider.dart';
import 'package:my_linkding/router/paths.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:my_linkding/router/routes.dart';
part 'router_provider.g.dart';
@Riverpod(keepAlive: true)
GoRouter router(RouterRef ref) {
return GoRouter(
redirect: (context, state) {
if (ref.watch(apiClientProviderProvider) == null) {
return RoutesPaths.connect;
}
return null;
},
initialLocation: RoutesPaths.links,
routes: appRoutes,
navigatorKey: rootNavigatorKey,
);
}

View File

@@ -0,0 +1,24 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'router_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$routerHash() => r'47e3f3d747dcb7b2543c73209f8a2f5561eb6ff7';
/// See also [router].
@ProviderFor(router)
final routerProvider = Provider<GoRouter>.internal(
router,
name: r'routerProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$routerHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef RouterRef = ProviderRef<GoRouter>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member

View File

@@ -0,0 +1,48 @@
import 'dart:convert';
import 'package:my_linkding/config/shared_preferences_keys.dart';
import 'package:my_linkding/providers/shared_preferences_provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:my_linkding/services/api_client.dart';
import 'package:my_linkding/providers/api_client_provider.dart';
import 'package:my_linkding/models/server_instance.dart';
part 'server_instances_provider.g.dart';
@Riverpod(keepAlive: true)
class ServerInstances extends _$ServerInstances {
@override
List<ServerInstance> build() {
final instances = ref.watch(sharedPreferencesProvider).getStringList(SharedPreferencesKeys.serversInstances);
if (instances == null) return [];
List<ServerInstance> generatedInstances = [];
for (final instance in instances) {
try {
generatedInstances.add(ServerInstance.fromJson(jsonDecode(instance)));
} catch (_) {/* IF ERROR OMIT */}
}
return generatedInstances;
}
ServerInstance? getInstance(String id) {
try {
return state.firstWhere((instance) => instance.id == id);
} catch (_) {
return null;
}
}
void saveNewInstance(ServerInstance instance) {
state = [instance];
saveServerInstances([instance]);
}
void saveServerInstances(List<ServerInstance> instances) async {
final stringList = instances.map((i) => jsonEncode(i.toJson())).toList();
ref.read(sharedPreferencesProvider).setStringList(SharedPreferencesKeys.serversInstances, stringList);
}
}

View File

@@ -0,0 +1,26 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'server_instances_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$serverInstancesHash() => r'82b7d3367afbe5c19ad0238977e5df35fac3499b';
/// See also [ServerInstances].
@ProviderFor(ServerInstances)
final serverInstancesProvider =
NotifierProvider<ServerInstances, List<ServerInstance>>.internal(
ServerInstances.new,
name: r'serverInstancesProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$serverInstancesHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$ServerInstances = Notifier<List<ServerInstance>>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member

View File

@@ -0,0 +1,6 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shared_preferences/shared_preferences.dart';
final sharedPreferencesProvider = Provider<SharedPreferences>((ref) {
throw UnimplementedError();
});

6
lib/router/paths.dart Normal file
View File

@@ -0,0 +1,6 @@
class RoutesPaths {
static const connect = "/connect";
static const links = "/links";
static const search = "/search";
static const settings = "/settings";
}

59
lib/router/routes.dart Normal file
View File

@@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:my_linkding/screens/connect/ui/connect.dart';
import 'package:my_linkding/screens/links/ui/links.dart';
import 'package:my_linkding/screens/search/ui/search.dart';
import 'package:my_linkding/screens/settings/ui/settings.dart';
import 'package:my_linkding/widgets/layout.dart';
import 'package:my_linkding/router/paths.dart';
final rootNavigatorKey = GlobalKey<NavigatorState>();
final linksNavigatorKey = GlobalKey<NavigatorState>();
final searchNavigatorKey = GlobalKey<NavigatorState>();
final settingsNavigatorKey = GlobalKey<NavigatorState>();
final List<RouteBase> appRoutes = [
GoRoute(
path: RoutesPaths.connect,
builder: (context, state) => const Connect(),
),
StatefulShellRoute.indexedStack(
builder: (context, state, navigationShell) => Layout(
navigationShell: navigationShell,
),
branches: [
StatefulShellBranch(
navigatorKey: linksNavigatorKey,
initialLocation: RoutesPaths.links,
routes: [
GoRoute(
path: RoutesPaths.links,
builder: (context, state) => const Links(),
),
],
),
StatefulShellBranch(
navigatorKey: searchNavigatorKey,
initialLocation: RoutesPaths.search,
routes: [
GoRoute(
path: RoutesPaths.search,
builder: (context, state) => const Search(),
),
],
),
StatefulShellBranch(
navigatorKey: settingsNavigatorKey,
initialLocation: RoutesPaths.settings,
routes: [
GoRoute(
path: RoutesPaths.settings,
builder: (context, state) => const Settings(),
),
],
),
],
),
];

View File

@@ -0,0 +1,53 @@
import 'package:flutter/material.dart';
import 'package:my_linkding/helpers/wrapped_class.dart';
class ConnectModel {
final bool isConnecting;
final int method;
final TextEditingController ipDomainController;
final String? ipDomainError;
final TextEditingController portController;
final String? portError;
final TextEditingController pathController;
final String? pathError;
final TextEditingController tokenController;
final String? tokenError;
ConnectModel({
required this.isConnecting,
required this.method,
required this.ipDomainController,
required this.ipDomainError,
required this.portController,
required this.portError,
required this.pathController,
required this.pathError,
required this.tokenController,
required this.tokenError,
});
ConnectModel copyWidth({
bool? isConnecting,
int? method,
TextEditingController? ipDomainController,
Wrapped<String?>? ipDomainError,
TextEditingController? portController,
Wrapped<String?>? portError,
TextEditingController? pathController,
Wrapped<String?>? pathError,
TextEditingController? tokenController,
Wrapped<String?>? tokenError,
}) =>
ConnectModel(
isConnecting: isConnecting ?? this.isConnecting,
method: method ?? this.method,
ipDomainController: ipDomainController ?? this.ipDomainController,
ipDomainError: ipDomainError != null ? ipDomainError.value : this.ipDomainError,
portController: portController ?? this.portController,
portError: portError != null ? portError.value : this.portError,
pathController: pathController ?? this.pathController,
pathError: pathError != null ? pathError.value : this.pathError,
tokenController: tokenController ?? this.tokenController,
tokenError: tokenError != null ? tokenError.value : this.tokenError,
);
}

View File

@@ -0,0 +1,106 @@
import 'package:flutter/material.dart';
import 'package:my_linkding/providers/shared_preferences_provider.dart';
import 'package:uuid/uuid.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:my_linkding/screens/connect/model/connect_model.dart';
import 'package:my_linkding/providers/server_instances_provider.dart';
import 'package:my_linkding/constants/enums.dart';
import 'package:my_linkding/utils/process_modal.dart';
import 'package:my_linkding/providers/router_provider.dart';
import 'package:my_linkding/router/paths.dart';
import 'package:my_linkding/utils/snackbar.dart';
import 'package:my_linkding/models/server_instance.dart';
import 'package:my_linkding/providers/api_client_provider.dart';
import 'package:my_linkding/services/api_client.dart';
import 'package:my_linkding/constants/regexp.dart';
import 'package:my_linkding/helpers/wrapped_class.dart';
part 'connect_provider.g.dart';
@riverpod
class Connect extends _$Connect {
@override
ConnectModel build() {
return ConnectModel(
isConnecting: false,
method: 0,
ipDomainController: TextEditingController(),
ipDomainError: null,
portController: TextEditingController(),
portError: null,
pathController: TextEditingController(),
pathError: null,
tokenController: TextEditingController(),
tokenError: null,
);
}
void setConnectionMethod(int method) {
state = state.copyWidth(method: method);
}
void validateIpDomain(String value) {
if (Regexps.ipAddress.hasMatch(value) || Regexps.domain.hasMatch(value)) {
state = state.copyWidth(ipDomainError: const Wrapped.value(null));
} else {
state = state.copyWidth(ipDomainError: const Wrapped.value("Error"));
}
}
void validatePort(value) {
if (value != null && value != '') {
if (int.tryParse(value) != null && int.parse(value) <= 65535) {
state = state.copyWidth(portError: const Wrapped.value(null));
} else {
state = state.copyWidth(portError: const Wrapped.value("Invalid port"));
}
}
}
void validateToken(String value) {
if (value != "") {
state = state.copyWidth(tokenError: const Wrapped.value(null));
} else {
state = state.copyWidth(tokenError: const Wrapped.value("Error"));
}
}
bool validValues() {
return state.tokenController.text != "" &&
state.tokenError == null &&
state.ipDomainController.text != "" &&
state.ipDomainError == null;
}
}
@riverpod
FutureOr<bool> connectToServer(ConnectToServerRef ref) async {
const uuid = Uuid();
final serverInstance = ServerInstance(
id: uuid.v4(),
method: ConnectionMethod.values[ref.watch(connectProvider).method].name,
ipDomain: ref.watch(connectProvider).ipDomainController.text,
token: ref.watch(connectProvider).tokenController.text,
);
final apiClient = ApiClient(serverInstance: serverInstance);
final processModal = ProcessModal();
processModal.open("Connecting...");
final result = await apiClient.checkConnectionInstance();
processModal.close();
if (result == true) {
ref.read(apiClientProviderProvider.notifier).setApiClient(apiClient);
ref.read(serverInstancesProvider.notifier).saveNewInstance(serverInstance);
// ref.watch(routerProvider).replace(RoutesPaths.links);
} else {
showSnacbkar(label: "Invalida auth", color: Colors.red);
}
return false;
}

View File

@@ -0,0 +1,40 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'connect_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$connectToServerHash() => r'af66fee9a4bf26aba08b27ff5a7fe4643215117a';
/// See also [connectToServer].
@ProviderFor(connectToServer)
final connectToServerProvider = AutoDisposeFutureProvider<bool>.internal(
connectToServer,
name: r'connectToServerProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$connectToServerHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef ConnectToServerRef = AutoDisposeFutureProviderRef<bool>;
String _$connectHash() => r'bbdae015118381c538b7a3c15ca8345d30a28995';
/// See also [Connect].
@ProviderFor(Connect)
final connectProvider =
AutoDisposeNotifierProvider<Connect, ConnectModel>.internal(
Connect.new,
name: r'connectProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$connectHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$Connect = AutoDisposeNotifier<ConnectModel>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member

View File

@@ -0,0 +1,124 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:my_linkding/constants/enums.dart';
import 'package:my_linkding/screens/connect/provider/connect_provider.dart';
import 'package:segmented_button_slide/segmented_button_slide.dart';
class Connect extends ConsumerWidget {
const Connect({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final provider = ref.watch(connectProvider);
final validValies = ref.read(connectProvider.notifier).validValues();
final connectionMethod = ConnectionMethod.values[provider.method];
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: ListView(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
margin: const EdgeInsets.only(top: 24, left: 24, right: 24),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary.withOpacity(0.05),
borderRadius: BorderRadius.circular(30),
border: Border.all(color: Theme.of(context).colorScheme.primary),
),
child: Text(
"${connectionMethod.name}://${provider.ipDomainController.text}${provider.pathController.text != '' ? ':${provider.portController.text}' : ""}${provider.pathController.text}",
textAlign: TextAlign.center,
style: TextStyle(color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.w500),
),
),
const SizedBox(height: 30),
SegmentedButtonSlide(
entries: const [
SegmentedButtonSlideEntry(label: "HTTP"),
SegmentedButtonSlideEntry(label: "HTTPS"),
],
selectedEntry: provider.method,
onChange: ref.read(connectProvider.notifier).setConnectionMethod,
colors: SegmentedButtonSlideColors(
barColor: Theme.of(context).colorScheme.primary.withOpacity(0.2),
backgroundSelectedColor: Theme.of(context).colorScheme.primary,
foregroundSelectedColor: Theme.of(context).colorScheme.onPrimary,
foregroundUnselectedColor: Theme.of(context).colorScheme.onSurface,
hoverColor: Theme.of(context).colorScheme.onSurfaceVariant,
),
textOverflow: TextOverflow.ellipsis,
fontSize: 14,
height: 40,
margin: const EdgeInsets.symmetric(
horizontal: 24,
),
),
const SizedBox(height: 30),
TextFormField(
controller: provider.ipDomainController,
onChanged: ref.read(connectProvider.notifier).validateIpDomain,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.link_rounded),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(
Radius.circular(10),
),
),
errorText: provider.ipDomainError,
labelText: "IP address or domain",
),
),
const SizedBox(height: 30),
TextFormField(
controller: provider.portController,
onChanged: ref.read(connectProvider.notifier).validatePort,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.link_rounded),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(
Radius.circular(10),
),
),
errorText: provider.portError,
labelText: "Port",
),
),
const SizedBox(height: 30),
TextFormField(
controller: provider.tokenController,
onChanged: ref.read(connectProvider.notifier).validateToken,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.key_rounded),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(
Radius.circular(10),
),
),
errorText: provider.tokenError,
labelText: "Token",
),
obscureText: true,
),
const SizedBox(height: 30),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
FilledButton.icon(
onPressed: validValies ? () => ref.read(connectToServerProvider) : null,
icon: const Icon(Icons.login_rounded),
label: Text("Connect"),
),
],
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,9 @@
import 'package:my_linkding/constants/enums.dart';
class LinksModel {
final LoadStatus loadStatus;
LinksModel({
required this.loadStatus,
});
}

View File

@@ -0,0 +1,92 @@
import 'package:flutter/material.dart';
class Links extends StatelessWidget {
const Links({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
child: NestedScrollView(
headerSliverBuilder: (context, innerBoxIsScrolled) => [
SliverOverlapAbsorber(
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
sliver: SliverAppBar.large(
pinned: true,
floating: true,
centerTitle: false,
forceElevated: innerBoxIsScrolled,
title: Text("Links"),
),
),
],
body: SafeArea(
top: false,
bottom: false,
child: Builder(
builder: (context) => CustomScrollView(
slivers: [
SliverOverlapInjector(
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
),
SliverList.builder(
itemCount: 40,
itemBuilder: (context, index) => ListTile(
title: Text(
"This is the title",
style: TextStyle(
fontSize: 18,
color: Theme.of(context).colorScheme.onSurface,
),
),
subtitle: Column(
children: [
Text(
"All the speed he took, all the turns hed taken and the dripping chassis of a skyscraper canyon. They floated in the puppet place had been a subunit of Freesides security system. The knives seemed to move of their own accord, gliding with a ritual lack of urgency through the center of his closed left eyelid. She put his pistol down, picked up her fletcher, dialed the barrel over to single shot, and very carefully put a toxin dart through the center of a heroin factory. Still it was a square of faint light. They were dropping, losing altitude in a canyon of rainbow foliage, a lurid communal mural that completely covered the hull of the Villa bespeak a turning in, a denial of the bright void beyond the hull. A narrow wedge of light from a half-open service hatch at the clinic, Molly took him to the simple Chinese hollow points Shin had sold him. Still it was a steady pulse of pain midway down his spine. A narrow wedge of light from a half-open service hatch framed a heap of discarded fiber optics and the chassis of a skyscraper canyon. The Tessier-Ashpool ice shattered, peeling away from the banks of every computer in the center of his closed left eyelid.",
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 14,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Row(
children: [
Expanded(
child: Text(
"#flutter #apps",
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
),
Container(
width: 1,
height: 12,
margin: const EdgeInsets.symmetric(horizontal: 8),
color: Theme.of(context).dividerColor,
),
Text(
"1 week ago",
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
],
),
),
),
],
),
),
),
),
);
}
}

View File

@@ -0,0 +1,10 @@
import 'package:flutter/material.dart';
class Search extends StatelessWidget {
const Search({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container();
}
}

View File

@@ -0,0 +1,39 @@
import 'package:flutter/material.dart';
class Settings extends StatelessWidget {
const Settings({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
child: NestedScrollView(
headerSliverBuilder: (context, innerBoxIsScrolled) => [
SliverOverlapAbsorber(
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
sliver: SliverAppBar.large(
pinned: true,
floating: true,
centerTitle: false,
forceElevated: innerBoxIsScrolled,
title: Text("Settings"),
),
),
],
body: SafeArea(
top: false,
bottom: false,
child: Builder(
builder: (context) => CustomScrollView(
slivers: [
SliverOverlapInjector(
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
),
],
),
),
),
),
);
}
}

View File

@@ -0,0 +1,19 @@
import 'package:dio/dio.dart';
import 'package:my_linkding/models/server_instance.dart';
import 'package:my_linkding/utils/http_request_client.dart';
final dio = Dio();
class ApiClient {
final ServerInstance serverInstance;
const ApiClient({
required this.serverInstance,
});
Future<bool> checkConnectionInstance() async {
final result = await HttpRequestClient.get(urlPath: "/bookmarks/?limit=1", server: serverInstance);
return result.successful;
}
}

View File

@@ -0,0 +1,9 @@
import 'dart:io';
class MyHttpOverrides extends HttpOverrides {
@override
HttpClient createHttpClient(SecurityContext? context) {
return super.createHttpClient(context)
..badCertificateCallback = (X509Certificate cert, String host, int port) => true;
}
}

View File

@@ -0,0 +1,210 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:my_linkding/models/server_instance.dart';
enum ExceptionType { socket, timeout, handshake, http, unknown }
class HttpResponse {
final bool successful;
final String? body;
final int? statusCode;
final ExceptionType? exception;
const HttpResponse({
required this.successful,
required this.body,
required this.statusCode,
this.exception,
});
}
String getConnectionString({
required ServerInstance server,
required String urlPath,
}) {
return "${server.method}://${server.ipDomain}${server.port != null ? ':${server.port}' : ""}${server.path ?? ""}/api$urlPath";
}
class HttpRequestClient {
static Future<HttpResponse> get({
required String urlPath,
required ServerInstance server,
int timeout = 10,
}) async {
final String connectionString = getConnectionString(server: server, urlPath: urlPath);
try {
HttpClient httpClient = HttpClient();
HttpClientRequest request = await httpClient.getUrl(Uri.parse(connectionString));
request.headers.set('Authorization', 'Token ${server.token}');
HttpClientResponse response = await request.close().timeout(
Duration(seconds: timeout),
);
String reply = await response.transform(utf8.decoder).join();
httpClient.close();
return HttpResponse(
successful: response.statusCode >= 400 ? false : true,
body: reply,
statusCode: response.statusCode,
);
} on SocketException {
return const HttpResponse(
successful: false,
body: null,
statusCode: null,
exception: ExceptionType.socket,
);
} on TimeoutException {
return const HttpResponse(
successful: false,
body: null,
statusCode: null,
exception: ExceptionType.timeout,
);
} on HandshakeException {
return const HttpResponse(
successful: false,
body: null,
statusCode: null,
exception: ExceptionType.handshake,
);
} on HttpException {
return const HttpResponse(
successful: false,
body: null,
statusCode: null,
exception: ExceptionType.http,
);
} catch (e) {
return const HttpResponse(
successful: false,
body: null,
statusCode: null,
exception: ExceptionType.unknown,
);
}
}
static Future<HttpResponse> post({
required String urlPath,
required ServerInstance server,
dynamic body,
int timeout = 10,
}) async {
final String connectionString = getConnectionString(server: server, urlPath: urlPath);
try {
HttpClient httpClient = HttpClient();
HttpClientRequest request = await httpClient.postUrl(Uri.parse(connectionString));
request.headers.set('Authorization', 'Token ${server.token}');
request.headers.set('content-type', 'application/json');
request.add(utf8.encode(json.encode(body)));
HttpClientResponse response = await request.close().timeout(
Duration(seconds: timeout),
);
String reply = await response.transform(utf8.decoder).join();
httpClient.close();
return HttpResponse(
successful: response.statusCode >= 400 ? false : true,
body: reply,
statusCode: response.statusCode,
);
} on SocketException {
return const HttpResponse(
successful: false,
body: null,
statusCode: null,
exception: ExceptionType.socket,
);
} on TimeoutException {
return const HttpResponse(
successful: false,
body: null,
statusCode: null,
exception: ExceptionType.timeout,
);
} on HttpException {
return const HttpResponse(
successful: false,
body: null,
statusCode: null,
exception: ExceptionType.http,
);
} on HandshakeException {
return const HttpResponse(
successful: false,
body: null,
statusCode: null,
exception: ExceptionType.handshake,
);
} catch (e) {
return const HttpResponse(
successful: false,
body: null,
statusCode: null,
exception: ExceptionType.unknown,
);
}
}
static Future<HttpResponse> put({
required String urlPath,
required ServerInstance server,
dynamic body,
int timeout = 10,
}) async {
final String connectionString = getConnectionString(server: server, urlPath: urlPath);
try {
HttpClient httpClient = HttpClient();
HttpClientRequest request = await httpClient.putUrl(Uri.parse(connectionString));
request.headers.set('Authorization', 'Token ${server.token}');
request.headers.set('content-type', 'application/json');
request.add(utf8.encode(json.encode(body)));
HttpClientResponse response = await request.close().timeout(
Duration(seconds: timeout),
);
String reply = await response.transform(utf8.decoder).join();
httpClient.close();
return HttpResponse(
successful: response.statusCode >= 400 ? false : true,
body: reply,
statusCode: response.statusCode,
);
} on SocketException {
return const HttpResponse(
successful: false,
body: null,
statusCode: null,
exception: ExceptionType.socket,
);
} on TimeoutException {
return const HttpResponse(
successful: false,
body: null,
statusCode: null,
exception: ExceptionType.timeout,
);
} on HttpException {
return const HttpResponse(
successful: false,
body: null,
statusCode: null,
exception: ExceptionType.http,
);
} on HandshakeException {
return const HttpResponse(
successful: false,
body: null,
statusCode: null,
exception: ExceptionType.handshake,
);
} catch (e) {
return const HttpResponse(
successful: false,
body: null,
statusCode: null,
exception: ExceptionType.unknown,
);
}
}
}

View File

@@ -0,0 +1,29 @@
import 'package:flutter/material.dart';
import 'package:my_linkding/widgets/process_dialog.dart';
import 'package:my_linkding/router/routes.dart';
class ProcessModal {
void open(String message) async {
await Future.delayed(
const Duration(seconds: 0),
() => {
showDialog(
context: rootNavigatorKey.currentContext!,
builder: (ctx) {
return ProcessDialog(
message: message,
);
},
barrierDismissible: false,
useSafeArea: true,
),
},
);
}
void close() {
Navigator.pop(rootNavigatorKey.currentContext!);
}
}

22
lib/utils/snackbar.dart Normal file
View File

@@ -0,0 +1,22 @@
// ignore_for_file: use_build_context_synchronously
import 'package:flutter/material.dart';
import 'package:my_linkding/constants/global_keys.dart';
void showSnacbkar({
required String label,
required Color color,
Color? labelColor,
GlobalKey<ScaffoldMessengerState>? key,
}) async {
final GlobalKey<ScaffoldMessengerState> scaffoldKey = key ?? scaffoldMessengerGlobalKey;
final snackBar = SnackBar(
content: Text(
label,
style: TextStyle(color: labelColor ?? Colors.white),
),
backgroundColor: color,
);
scaffoldKey.currentState?.showSnackBar(snackBar);
}

52
lib/widgets/layout.dart Normal file
View File

@@ -0,0 +1,52 @@
import 'package:animations/animations.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
class Layout extends StatelessWidget {
final StatefulNavigationShell navigationShell;
const Layout({
Key? key,
required this.navigationShell,
}) : super(key: key);
@override
Widget build(BuildContext context) {
void goBranch(int index) {
navigationShell.goBranch(
index,
initialLocation: index == navigationShell.currentIndex,
);
}
return Scaffold(
body: PageTransitionSwitcher(
duration: const Duration(milliseconds: 200),
transitionBuilder: (child, primaryAnimation, secondaryAnimation) => FadeThroughTransition(
animation: primaryAnimation,
secondaryAnimation: secondaryAnimation,
child: child,
),
child: navigationShell,
),
bottomNavigationBar: NavigationBar(
onDestinationSelected: (s) => goBranch(s),
selectedIndex: navigationShell.currentIndex,
destinations: const [
NavigationDestination(
icon: Icon(Icons.link_rounded),
label: "Links",
),
NavigationDestination(
icon: Icon(Icons.search_rounded),
label: "Search",
),
NavigationDestination(
icon: Icon(Icons.settings_rounded),
label: "Settings",
),
],
),
);
}
}

View File

@@ -0,0 +1,39 @@
import 'package:flutter/material.dart';
class ProcessDialog extends StatelessWidget {
final String message;
const ProcessDialog({
Key? key,
required this.message,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return WillPopScope(
onWillPop: () async => false,
child: Dialog(
backgroundColor: Theme.of(context).dialogBackgroundColor,
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 30,
horizontal: 30,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
const SizedBox(width: 40),
Flexible(
child: Text(
message,
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
),
)
],
),
),
),
);
}
}