├── lib ├── components │ ├── auth │ │ ├── widgets │ │ │ ├── widgets.dart │ │ │ └── text_field.dart │ │ ├── auth.dart │ │ ├── login │ │ │ ├── login_controller.dart │ │ │ └── login_page.dart │ │ └── register │ │ │ ├── register_controller.dart │ │ │ └── register_page.dart │ ├── document │ │ ├── document.dart │ │ ├── widgets │ │ │ ├── widgets.dart │ │ │ ├── all_documents_popup.dart │ │ │ └── menu_bar.dart │ │ ├── state │ │ │ ├── document_state.dart │ │ │ └── document_controller.dart │ │ ├── new_document_page.dart │ │ └── document_page.dart │ └── controller_state_base.dart ├── app │ ├── state │ │ ├── state.dart │ │ ├── state_base.dart │ │ └── auth_state.dart │ ├── constants.dart │ ├── navigation │ │ ├── routes.dart │ │ └── transition_page.dart │ ├── providers.dart │ ├── app.dart │ └── utils.dart ├── models │ ├── models.dart │ ├── app_error.dart │ ├── delta_data.dart │ └── document_page_data.dart ├── repositories │ ├── repositories.dart │ ├── repository_exception.dart │ ├── auth_repository.dart │ └── database_repository.dart └── main.dart ├── web ├── favicon.png ├── icons │ ├── Icon-192.png │ ├── Icon-512.png │ ├── Icon-maskable-192.png │ └── Icon-maskable-512.png ├── manifest.json └── index.html ├── .metadata ├── .gitignore ├── analysis_options.yaml ├── pubspec.yaml ├── README.md └── pubspec.lock /lib/components/auth/widgets/widgets.dart: -------------------------------------------------------------------------------- 1 | export 'text_field.dart'; 2 | -------------------------------------------------------------------------------- /lib/app/state/state.dart: -------------------------------------------------------------------------------- 1 | export 'auth_state.dart'; 2 | export 'state_base.dart'; 3 | -------------------------------------------------------------------------------- /lib/components/auth/auth.dart: -------------------------------------------------------------------------------- 1 | export 'login/login_page.dart'; 2 | export 'register/register_page.dart'; 3 | -------------------------------------------------------------------------------- /lib/components/document/document.dart: -------------------------------------------------------------------------------- 1 | export 'document_page.dart'; 2 | export 'new_document_page.dart'; 3 | -------------------------------------------------------------------------------- /web/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/funwithflutter/google-docs-clone-flutter/HEAD/web/favicon.png -------------------------------------------------------------------------------- /lib/components/document/widgets/widgets.dart: -------------------------------------------------------------------------------- 1 | export 'all_documents_popup.dart'; 2 | export 'menu_bar.dart'; 3 | -------------------------------------------------------------------------------- /lib/models/models.dart: -------------------------------------------------------------------------------- 1 | export 'app_error.dart'; 2 | export 'document_page_data.dart'; 3 | export 'delta_data.dart'; 4 | -------------------------------------------------------------------------------- /web/icons/Icon-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/funwithflutter/google-docs-clone-flutter/HEAD/web/icons/Icon-192.png -------------------------------------------------------------------------------- /web/icons/Icon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/funwithflutter/google-docs-clone-flutter/HEAD/web/icons/Icon-512.png -------------------------------------------------------------------------------- /web/icons/Icon-maskable-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/funwithflutter/google-docs-clone-flutter/HEAD/web/icons/Icon-maskable-192.png -------------------------------------------------------------------------------- /web/icons/Icon-maskable-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/funwithflutter/google-docs-clone-flutter/HEAD/web/icons/Icon-maskable-512.png -------------------------------------------------------------------------------- /lib/repositories/repositories.dart: -------------------------------------------------------------------------------- 1 | export 'auth_repository.dart'; 2 | export 'database_repository.dart'; 3 | export 'repository_exception.dart'; 4 | -------------------------------------------------------------------------------- /lib/main.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 | import 'package:google_docs_clone/app/app.dart'; 4 | 5 | void main() { 6 | setupLogger(); 7 | 8 | runApp(const ProviderScope(child: GoogleDocsApp())); 9 | } 10 | -------------------------------------------------------------------------------- /lib/app/state/state_base.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | 3 | import 'package:google_docs_clone/models/models.dart'; 4 | 5 | class StateBase extends Equatable { 6 | final AppError? error; 7 | 8 | const StateBase({this.error}); 9 | 10 | @override 11 | List get props => [error]; 12 | } 13 | -------------------------------------------------------------------------------- /.metadata: -------------------------------------------------------------------------------- 1 | # This file tracks properties of this Flutter project. 2 | # Used by Flutter tool to assess capabilities and perform upgrades etc. 3 | # 4 | # This file should be version controlled and should not be manually edited. 5 | 6 | version: 7 | revision: db747aa1331bd95bc9b3874c842261ca2d302cd5 8 | channel: stable 9 | 10 | project_type: app 11 | -------------------------------------------------------------------------------- /lib/models/app_error.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | 3 | class AppError extends Equatable { 4 | AppError({ 5 | required this.message, 6 | }) { 7 | timestamp = DateTime.now().microsecondsSinceEpoch; 8 | } 9 | 10 | final String message; 11 | late final int timestamp; 12 | 13 | @override 14 | List get props => [message, timestamp]; 15 | } 16 | -------------------------------------------------------------------------------- /lib/app/constants.dart: -------------------------------------------------------------------------------- 1 | const appwriteEndpoint = 'http://localhost/v1'; 2 | const appwriteProjectId = '6241851fa5fe35076532'; // TODO: modify this 3 | 4 | abstract class CollectionNames { 5 | static String get delta => 'delta'; 6 | static String get deltaDocumentsPath => 'collections.$delta.documents'; 7 | static String get pages => 'pages'; 8 | static String get pagesDocumentsPath => 'collections.$pages.documents'; 9 | } 10 | -------------------------------------------------------------------------------- /lib/components/controller_state_base.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | 3 | import 'package:google_docs_clone/models/models.dart'; 4 | 5 | class ControllerStateBase extends Equatable { 6 | const ControllerStateBase({this.error}); 7 | 8 | final AppError? error; 9 | 10 | @override 11 | List get props => [error]; 12 | 13 | ControllerStateBase copyWith({AppError? error}) => 14 | ControllerStateBase(error: error ?? this.error); 15 | } 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Miscellaneous 2 | *.class 3 | *.log 4 | *.pyc 5 | *.swp 6 | .DS_Store 7 | .atom/ 8 | .buildlog/ 9 | .history 10 | .svn/ 11 | 12 | # IntelliJ related 13 | *.iml 14 | *.ipr 15 | *.iws 16 | .idea/ 17 | 18 | # The .vscode folder contains launch configuration and tasks you configure in 19 | # VS Code which you may wish to be included in version control, so this line 20 | # is commented out by default. 21 | #.vscode/ 22 | 23 | # Flutter/Dart/Pub related 24 | **/doc/api/ 25 | **/ios/Flutter/.last_build_id 26 | .dart_tool/ 27 | .flutter-plugins 28 | .flutter-plugins-dependencies 29 | .packages 30 | .pub-cache/ 31 | .pub/ 32 | /build/ 33 | 34 | # Web related 35 | lib/generated_plugin_registrant.dart 36 | 37 | # Symbolication related 38 | app.*.symbols 39 | 40 | # Obfuscation related 41 | app.*.map.json 42 | 43 | # Android Studio will place build artifacts here 44 | /android/app/debug 45 | /android/app/profile 46 | /android/app/release 47 | -------------------------------------------------------------------------------- /web/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "google_docs_clone", 3 | "short_name": "google_docs_clone", 4 | "start_url": ".", 5 | "display": "standalone", 6 | "background_color": "#0175C2", 7 | "theme_color": "#0175C2", 8 | "description": "A new Flutter project.", 9 | "orientation": "portrait-primary", 10 | "prefer_related_applications": false, 11 | "icons": [ 12 | { 13 | "src": "icons/Icon-192.png", 14 | "sizes": "192x192", 15 | "type": "image/png" 16 | }, 17 | { 18 | "src": "icons/Icon-512.png", 19 | "sizes": "512x512", 20 | "type": "image/png" 21 | }, 22 | { 23 | "src": "icons/Icon-maskable-192.png", 24 | "sizes": "192x192", 25 | "type": "image/png", 26 | "purpose": "maskable" 27 | }, 28 | { 29 | "src": "icons/Icon-maskable-512.png", 30 | "sizes": "512x512", 31 | "type": "image/png", 32 | "purpose": "maskable" 33 | } 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /lib/repositories/repository_exception.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:appwrite/appwrite.dart'; 4 | import 'package:google_docs_clone/app/utils.dart'; 5 | 6 | class RepositoryException implements Exception { 7 | const RepositoryException( 8 | {required this.message, this.exception, this.stackTrace}); 9 | 10 | final String message; 11 | final Exception? exception; 12 | final StackTrace? stackTrace; 13 | 14 | @override 15 | String toString() { 16 | return "RepositoryException: $message)"; 17 | } 18 | } 19 | 20 | mixin RepositoryExceptionMixin { 21 | Future exceptionHandler( 22 | FutureOr computation, { 23 | String unkownMessage = 'Repository Exception', 24 | }) async { 25 | try { 26 | return await computation; 27 | } on AppwriteException catch (e) { 28 | logger.warning(e.message, e); 29 | throw RepositoryException( 30 | message: e.message ?? 'An undefined error occured'); 31 | } on Exception catch (e, st) { 32 | logger.severe(unkownMessage, e, st); 33 | throw RepositoryException( 34 | message: unkownMessage, exception: e, stackTrace: st); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /lib/components/document/state/document_state.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_quill/flutter_quill.dart'; 2 | import 'package:google_docs_clone/components/controller_state_base.dart'; 3 | 4 | import '../../../models/models.dart'; 5 | 6 | class DocumentState extends ControllerStateBase { 7 | const DocumentState({ 8 | required this.id, 9 | this.documentPageData, 10 | this.quillDocument, 11 | this.quillController, 12 | this.isSavedRemotely = false, 13 | AppError? error, 14 | }) : super(error: error); 15 | 16 | final String id; 17 | final DocumentPageData? documentPageData; 18 | final Document? quillDocument; 19 | final QuillController? quillController; 20 | final bool isSavedRemotely; 21 | 22 | @override 23 | List get props => [id, error]; 24 | 25 | @override 26 | DocumentState copyWith({ 27 | String? id, 28 | DocumentPageData? documentPageData, 29 | Document? quillDocument, 30 | QuillController? quillController, 31 | bool? isSavedRemotely, 32 | AppError? error, 33 | }) { 34 | return DocumentState( 35 | id: id ?? this.id, 36 | documentPageData: documentPageData ?? this.documentPageData, 37 | quillDocument: quillDocument ?? this.quillDocument, 38 | quillController: quillController ?? this.quillController, 39 | isSavedRemotely: isSavedRemotely ?? this.isSavedRemotely, 40 | error: error ?? this.error, 41 | ); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /lib/models/delta_data.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:equatable/equatable.dart'; 4 | 5 | class DeltaData extends Equatable { 6 | final String user; 7 | 8 | /// String json Representation of delta 9 | final String delta; 10 | final String deviceId; 11 | 12 | const DeltaData({ 13 | required this.user, 14 | required this.delta, 15 | required this.deviceId, 16 | }); 17 | 18 | DeltaData copyWith({ 19 | String? user, 20 | String? delta, 21 | String? deviceId, 22 | }) { 23 | return DeltaData( 24 | user: user ?? this.user, 25 | delta: delta ?? this.delta, 26 | deviceId: deviceId ?? this.deviceId, 27 | ); 28 | } 29 | 30 | Map toMap() { 31 | return { 32 | 'user': user, 33 | 'delta': delta, 34 | 'deviceId': deviceId, 35 | }; 36 | } 37 | 38 | factory DeltaData.fromMap(Map map) { 39 | return DeltaData( 40 | user: map['user'] ?? '', 41 | delta: map['delta'] ?? '', 42 | deviceId: map['deviceId'] ?? '', 43 | ); 44 | } 45 | 46 | String toJson() => json.encode(toMap()); 47 | 48 | factory DeltaData.fromJson(String source) => 49 | DeltaData.fromMap(json.decode(source)); 50 | 51 | @override 52 | String toString() => 53 | 'DeltaData(user: $user, delta: $delta, deviceId: $deviceId)'; 54 | 55 | @override 56 | List get props => [user, delta, deviceId]; 57 | } 58 | -------------------------------------------------------------------------------- /lib/models/document_page_data.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:equatable/equatable.dart'; 4 | import 'package:flutter_quill/flutter_quill.dart'; 5 | 6 | class DocumentPageData extends Equatable { 7 | final String id; 8 | final String title; 9 | final Delta content; 10 | 11 | const DocumentPageData({ 12 | required this.id, 13 | required this.title, 14 | required this.content, 15 | }); 16 | 17 | Map toMap() { 18 | return { 19 | '\$id': id, 20 | 'title': title, 21 | 'content': jsonEncode(content.toJson()), 22 | }; 23 | } 24 | 25 | factory DocumentPageData.fromMap(Map map) { 26 | final contentJson = 27 | (map['content'] == null) ? [] : jsonDecode(map['content']); 28 | return DocumentPageData( 29 | id: map['\$id'], 30 | title: map['title'] ?? '', 31 | content: Delta.fromJson(contentJson), 32 | ); 33 | } 34 | 35 | String toJson() => json.encode(toMap()); 36 | 37 | factory DocumentPageData.fromJson(String source) => 38 | DocumentPageData.fromMap(json.decode(source)); 39 | 40 | @override 41 | List get props => [title, content]; 42 | 43 | DocumentPageData copyWith({ 44 | String? id, 45 | String? title, 46 | Delta? content, 47 | }) { 48 | return DocumentPageData( 49 | id: id ?? this.id, 50 | title: title ?? this.title, 51 | content: content ?? this.content, 52 | ); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /lib/components/auth/login/login_controller.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 2 | import 'package:google_docs_clone/app/providers.dart'; 3 | import 'package:google_docs_clone/components/controller_state_base.dart'; 4 | import 'package:google_docs_clone/models/models.dart'; 5 | import 'package:google_docs_clone/repositories/repositories.dart'; 6 | 7 | final _loginControllerProvider = 8 | StateNotifierProvider( 9 | (ref) => LoginController(ref.read), 10 | ); 11 | 12 | class LoginController extends StateNotifier { 13 | LoginController(this._read) : super(const ControllerStateBase()); 14 | 15 | static StateNotifierProvider 16 | get provider => _loginControllerProvider; 17 | 18 | static AlwaysAliveProviderBase get notifier => 19 | provider.notifier; 20 | 21 | final Reader _read; 22 | 23 | Future createSession({ 24 | required String email, 25 | required String password, 26 | }) async { 27 | try { 28 | await _read(Repository.auth) 29 | .createSession(email: email, password: password); 30 | 31 | final user = await _read(Repository.auth).get(); 32 | 33 | /// Sets the global app state user. 34 | _read(AppState.auth.notifier).setUser(user); 35 | } on RepositoryException catch (e) { 36 | state = state.copyWith(error: AppError(message: e.message)); 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /lib/app/navigation/routes.dart: -------------------------------------------------------------------------------- 1 | import 'package:google_docs_clone/app/navigation/transition_page.dart'; 2 | import 'package:google_docs_clone/components/auth/auth.dart'; 3 | import 'package:routemaster/routemaster.dart'; 4 | 5 | import 'package:google_docs_clone/components/document/document.dart'; 6 | 7 | const _login = '/login'; 8 | const _register = '/register'; 9 | const _document = '/document'; 10 | const _newDocument = '/newDocument'; 11 | 12 | abstract class AppRoutes { 13 | static String get document => _document; 14 | static String get newDocument => _newDocument; 15 | static String get login => _login; 16 | static String get register => _register; 17 | } 18 | 19 | final routesLoggedOut = RouteMap( 20 | onUnknownRoute: (_) => const Redirect(_login), 21 | routes: { 22 | _login: (_) => const TransitionPage( 23 | child: LoginPage(), 24 | ), 25 | _register: (_) => const TransitionPage( 26 | child: RegisterPage(), 27 | ), 28 | }, 29 | ); 30 | 31 | final routesLoggedIn = RouteMap( 32 | onUnknownRoute: (_) => const Redirect(_newDocument), 33 | routes: { 34 | _newDocument: (_) => const TransitionPage(child: NewDocumentPage()), 35 | '$_document/:id': (info) { 36 | final docId = info.pathParameters['id']; 37 | if (docId == null) { 38 | return const Redirect(_newDocument); 39 | } 40 | return TransitionPage( 41 | child: DocumentPage(documentId: docId), 42 | ); 43 | }, 44 | }, 45 | ); 46 | -------------------------------------------------------------------------------- /lib/app/providers.dart: -------------------------------------------------------------------------------- 1 | import 'package:appwrite/appwrite.dart'; 2 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 | import 'package:google_docs_clone/app/constants.dart'; 4 | import 'package:google_docs_clone/app/state/state.dart'; 5 | import 'package:google_docs_clone/repositories/repositories.dart'; 6 | 7 | abstract class Dependency { 8 | static Provider get client => _clientProvider; 9 | static Provider get database => _databaseProvider; 10 | static Provider get account => _accountProvider; 11 | static Provider get realtime => _realtimeProvider; 12 | } 13 | 14 | abstract class Repository { 15 | static Provider get auth => AuthRepository.provider; 16 | static Provider get database => 17 | DatabaseRepository.provider; 18 | } 19 | 20 | abstract class AppState { 21 | static StateNotifierProvider get auth => 22 | AuthService.provider; 23 | } 24 | 25 | final _clientProvider = Provider( 26 | (ref) => Client() 27 | ..setProject(appwriteProjectId) 28 | ..setSelfSigned(status: true) 29 | ..setEndpoint(appwriteEndpoint), 30 | ); 31 | 32 | final _databaseProvider = 33 | Provider((ref) => Database(ref.read(_clientProvider))); 34 | 35 | final _accountProvider = Provider( 36 | (ref) => Account(ref.read(_clientProvider)), 37 | ); 38 | 39 | final _realtimeProvider = 40 | Provider((ref) => Realtime(ref.read(_clientProvider))); 41 | -------------------------------------------------------------------------------- /lib/app/app.dart: -------------------------------------------------------------------------------- 1 | export 'utils.dart'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 5 | import 'package:google_docs_clone/app/navigation/routes.dart'; 6 | import 'package:google_docs_clone/app/providers.dart'; 7 | import 'package:routemaster/routemaster.dart'; 8 | 9 | final _isAuthenticatedProvider = 10 | Provider((ref) => ref.watch(AppState.auth).isAuthenticated); 11 | 12 | final _isAuthLoading = 13 | Provider((ref) => ref.watch(AppState.auth).isLoading); 14 | 15 | class GoogleDocsApp extends ConsumerStatefulWidget { 16 | const GoogleDocsApp({Key? key}) : super(key: key); 17 | 18 | @override 19 | ConsumerState createState() => _GoogleDocsAppState(); 20 | } 21 | 22 | class _GoogleDocsAppState extends ConsumerState { 23 | @override 24 | Widget build(BuildContext context) { 25 | final isLoading = ref.watch(_isAuthLoading); 26 | if (isLoading) { 27 | return Container( 28 | color: Colors.white, 29 | ); 30 | } 31 | 32 | return MaterialApp.router( 33 | routerDelegate: RoutemasterDelegate(routesBuilder: (context) { 34 | final isAuthenticated = ref.watch(_isAuthenticatedProvider); 35 | return isAuthenticated ? routesLoggedIn : routesLoggedOut; 36 | }), 37 | routeInformationParser: const RoutemasterParser(), 38 | ); 39 | } 40 | } 41 | 42 | abstract class AppColors { 43 | static const secondary = Color(0xFF216BDD); 44 | } 45 | -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | # This file configures the analyzer, which statically analyzes Dart code to 2 | # check for errors, warnings, and lints. 3 | # 4 | # The issues identified by the analyzer are surfaced in the UI of Dart-enabled 5 | # IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be 6 | # invoked from the command line by running `flutter analyze`. 7 | 8 | # The following line activates a set of recommended lints for Flutter apps, 9 | # packages, and plugins designed to encourage good coding practices. 10 | include: package:flutter_lints/flutter.yaml 11 | 12 | linter: 13 | # The lint rules applied to this project can be customized in the 14 | # section below to disable rules from the `package:flutter_lints/flutter.yaml` 15 | # included above or to enable additional rules. A list of all available lints 16 | # and their documentation is published at 17 | # https://dart-lang.github.io/linter/lints/index.html. 18 | # 19 | # Instead of disabling a lint rule for the entire project in the 20 | # section below, it can also be suppressed for a single line of code 21 | # or a specific dart file by using the `// ignore: name_of_lint` and 22 | # `// ignore_for_file: name_of_lint` syntax on the line or in the file 23 | # producing the lint. 24 | rules: 25 | # avoid_print: false # Uncomment to disable the `avoid_print` rule 26 | # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule 27 | 28 | # Additional information about this file can be found at 29 | # https://dart.dev/guides/language/analysis-options 30 | -------------------------------------------------------------------------------- /lib/components/auth/register/register_controller.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 2 | import 'package:google_docs_clone/app/providers.dart'; 3 | import 'package:google_docs_clone/components/controller_state_base.dart'; 4 | import 'package:google_docs_clone/models/app_error.dart'; 5 | import 'package:google_docs_clone/repositories/repositories.dart'; 6 | 7 | final _registerControllerProvider = 8 | StateNotifierProvider( 9 | (ref) => RegisterController(ref.read), 10 | ); 11 | 12 | class RegisterController extends StateNotifier { 13 | RegisterController(this._read) : super(const ControllerStateBase()); 14 | 15 | static StateNotifierProvider 16 | get provider => _registerControllerProvider; 17 | 18 | static AlwaysAliveProviderBase get notifier => 19 | provider.notifier; 20 | 21 | final Reader _read; 22 | 23 | Future create({ 24 | required String email, 25 | required String password, 26 | required String name, 27 | }) async { 28 | try { 29 | final user = await _read(Repository.auth) 30 | .create(email: email, password: password, name: name); 31 | 32 | await _read(Repository.auth) 33 | .createSession(email: email, password: password); 34 | 35 | /// Sets the global app state user. 36 | _read(AppState.auth.notifier).setUser(user); 37 | } on RepositoryException catch (e) { 38 | state = state.copyWith(error: AppError(message: e.message)); 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /lib/repositories/auth_repository.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:appwrite/appwrite.dart'; 4 | import 'package:appwrite/models.dart'; 5 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 6 | import 'package:google_docs_clone/app/providers.dart'; 7 | import 'package:google_docs_clone/repositories/repository_exception.dart'; 8 | 9 | final _authRepositoryProvider = 10 | Provider((ref) => AuthRepository(ref.read)); 11 | 12 | class AuthRepository with RepositoryExceptionMixin { 13 | const AuthRepository(this._reader); 14 | 15 | static Provider get provider => _authRepositoryProvider; 16 | 17 | final Reader _reader; 18 | 19 | Account get _account => _reader(Dependency.account); 20 | 21 | Future create({ 22 | required String email, 23 | required String password, 24 | required String name, 25 | }) { 26 | return exceptionHandler( 27 | _account.create( 28 | userId: 'unique()', 29 | email: email, 30 | password: password, 31 | name: name, 32 | ), 33 | ); 34 | } 35 | 36 | Future createSession({ 37 | required String email, 38 | required String password, 39 | }) { 40 | return exceptionHandler( 41 | _account.createSession(email: email, password: password), 42 | ); 43 | } 44 | 45 | Future get() { 46 | return exceptionHandler( 47 | _account.get(), 48 | ); 49 | } 50 | 51 | Future deleteSession({required String sessionId}) { 52 | return exceptionHandler( 53 | _account.deleteSession(sessionId: sessionId), 54 | ); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /lib/components/document/new_document_page.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 | import 'package:google_docs_clone/app/navigation/routes.dart'; 4 | import 'package:google_docs_clone/app/providers.dart'; 5 | import 'package:google_docs_clone/repositories/repository_exception.dart'; 6 | import 'package:routemaster/routemaster.dart'; 7 | import 'package:uuid/uuid.dart'; 8 | 9 | class NewDocumentPage extends ConsumerStatefulWidget { 10 | const NewDocumentPage({Key? key}) : super(key: key); 11 | 12 | @override 13 | ConsumerState createState() => 14 | _NewDocumentPageState(); 15 | } 16 | 17 | class _NewDocumentPageState extends ConsumerState { 18 | final _uuid = const Uuid(); 19 | 20 | bool showError = false; 21 | 22 | @override 23 | void initState() { 24 | super.initState(); 25 | _createNewPage(); 26 | } 27 | 28 | Future _createNewPage() async { 29 | final documentId = _uuid.v4(); 30 | try { 31 | await ref.read(Repository.database).createNewPage( 32 | documentId: documentId, 33 | owner: ref.read(AppState.auth).user!.$id, 34 | ); 35 | 36 | Routemaster.of(context).push('${AppRoutes.document}/$documentId'); 37 | } on RepositoryException catch (_) { 38 | setState(() { 39 | showError = true; 40 | }); 41 | } 42 | } 43 | 44 | @override 45 | Widget build(BuildContext context) { 46 | if (showError) { 47 | return const Center( 48 | child: Text('An error occured'), 49 | ); 50 | } else { 51 | return const SizedBox(); 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /lib/app/utils.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/foundation.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 4 | import 'package:google_docs_clone/app/state/state.dart'; 5 | import 'package:google_docs_clone/components/controller_state_base.dart'; 6 | import 'package:logging/logging.dart'; 7 | 8 | final logger = Logger('App'); 9 | 10 | void setupLogger() { 11 | Logger.root.level = Level.ALL; 12 | Logger.root.onRecord.listen((record) { 13 | String emoji = ''; 14 | if (record.level == Level.INFO) { 15 | emoji = 'ℹ️'; 16 | } else if (record.level == Level.WARNING) { 17 | emoji = '❗️'; 18 | } else if (record.level == Level.SEVERE) { 19 | emoji = '⛔️'; 20 | } 21 | debugPrint('$emoji ${record.level.name}: ${record.message}'); 22 | if (record.error != null) { 23 | debugPrint('👉 ${record.error}'); 24 | } 25 | if (record.level == Level.SEVERE) { 26 | debugPrintStack(stackTrace: record.stackTrace); 27 | } 28 | }); 29 | } 30 | 31 | extension RefX on WidgetRef { 32 | void errorStateListener( 33 | BuildContext context, 34 | ProviderListenable provider, 35 | ) { 36 | listen(provider, ((previous, next) { 37 | final message = next.error?.message; 38 | if (next.error != previous?.error && 39 | message != null && 40 | message.isNotEmpty) { 41 | ScaffoldMessenger.of(context) 42 | .showSnackBar(SnackBar(content: Text(message))); 43 | } 44 | })); 45 | } 46 | 47 | void errorControllerStateListener( 48 | BuildContext context, 49 | ProviderListenable provider, 50 | ) { 51 | listen(provider, ((previous, next) { 52 | final message = next.error?.message; 53 | if (next.error != previous?.error && 54 | message != null && 55 | message.isNotEmpty) { 56 | ScaffoldMessenger.of(context) 57 | .showSnackBar(SnackBar(content: Text(message))); 58 | } 59 | })); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /lib/components/auth/widgets/text_field.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class PasswordTextField extends StatelessWidget { 4 | const PasswordTextField({ 5 | Key? key, 6 | required this.controller, 7 | }) : super(key: key); 8 | 9 | final TextEditingController controller; 10 | 11 | @override 12 | Widget build(BuildContext context) { 13 | return TextFormField( 14 | controller: controller, 15 | decoration: const InputDecoration(hintText: 'Password'), 16 | obscureText: true, 17 | validator: (value) { 18 | if (value == null || value.isEmpty) { 19 | return 'Cannot be empty'; 20 | } 21 | return null; 22 | }, 23 | ); 24 | } 25 | } 26 | 27 | class EmailTextField extends StatelessWidget { 28 | const EmailTextField({ 29 | Key? key, 30 | required this.controller, 31 | }) : super(key: key); 32 | 33 | final TextEditingController controller; 34 | 35 | @override 36 | Widget build(BuildContext context) { 37 | return TextFormField( 38 | controller: controller, 39 | decoration: const InputDecoration(hintText: 'Email'), 40 | validator: (value) { 41 | if (value == null || value.isEmpty) { 42 | return 'Cannot be empty'; 43 | } else if (!RegExp( 44 | r"^[a-zA-Z0-9.a-zA-Z0-9.!#$%&'*+-/=?^_`{|}~]+@[a-zA-Z0-9]+\.[a-zA-Z]+") 45 | .hasMatch(value)) { 46 | return 'Not a valid email address'; 47 | } 48 | return null; 49 | }, 50 | ); 51 | } 52 | } 53 | 54 | class NameTextField extends StatelessWidget { 55 | const NameTextField({ 56 | Key? key, 57 | required this.controller, 58 | }) : super(key: key); 59 | 60 | final TextEditingController controller; 61 | 62 | @override 63 | Widget build(BuildContext context) { 64 | return TextFormField( 65 | controller: controller, 66 | decoration: const InputDecoration(hintText: 'Name'), 67 | validator: (value) { 68 | if (value == null || value.isEmpty) { 69 | return 'Cannot be empty'; 70 | } 71 | return null; 72 | }, 73 | ); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /lib/app/state/auth_state.dart: -------------------------------------------------------------------------------- 1 | import 'package:appwrite/models.dart'; 2 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 | import 'package:google_docs_clone/app/providers.dart'; 4 | import 'package:google_docs_clone/app/state/state.dart'; 5 | import 'package:google_docs_clone/app/utils.dart'; 6 | import 'package:google_docs_clone/models/models.dart'; 7 | import 'package:google_docs_clone/repositories/repositories.dart'; 8 | 9 | final _authServiceProvider = StateNotifierProvider( 10 | (ref) => AuthService(ref.read)); 11 | 12 | class AuthService extends StateNotifier { 13 | AuthService(this._read) 14 | : super(const AuthState.unauthenticated(isLoading: true)) { 15 | refresh(); 16 | } 17 | 18 | static StateNotifierProvider get provider => 19 | _authServiceProvider; 20 | 21 | final Reader _read; 22 | 23 | Future refresh() async { 24 | try { 25 | final user = await _read(Repository.auth).get(); 26 | setUser(user); 27 | } on RepositoryException catch (_) { 28 | logger.info('Not authenticated'); 29 | state = const AuthState.unauthenticated(); 30 | } 31 | } 32 | 33 | void setUser(User user) { 34 | logger.info('Authentication successful, setting $user'); 35 | state = state.copyWith(user: user, isLoading: false); 36 | } 37 | 38 | Future signOut() async { 39 | try { 40 | await _read(Repository.auth).deleteSession(sessionId: 'current'); 41 | logger.info('Sign out successful'); 42 | state = const AuthState.unauthenticated(); 43 | } on RepositoryException catch (e) { 44 | state = state.copyWith(error: AppError(message: e.message)); 45 | } 46 | } 47 | } 48 | 49 | class AuthState extends StateBase { 50 | final User? user; 51 | final bool isLoading; 52 | 53 | const AuthState({ 54 | this.user, 55 | this.isLoading = false, 56 | AppError? error, 57 | }) : super(error: error); 58 | 59 | const AuthState.unauthenticated({this.isLoading = false}) 60 | : user = null, 61 | super(error: null); 62 | 63 | @override 64 | List get props => [user, isLoading, error]; 65 | 66 | bool get isAuthenticated => user != null; 67 | 68 | AuthState copyWith({ 69 | User? user, 70 | bool? isLoading, 71 | AppError? error, 72 | }) => 73 | AuthState( 74 | user: user ?? this.user, 75 | isLoading: isLoading ?? this.isLoading, 76 | error: error ?? this.error, 77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /lib/components/auth/login/login_page.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/gestures.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 4 | import 'package:google_docs_clone/app/navigation/routes.dart'; 5 | import 'package:google_docs_clone/app/utils.dart'; 6 | import 'package:google_docs_clone/components/auth/login/login_controller.dart'; 7 | import 'package:google_docs_clone/components/auth/widgets/widgets.dart'; 8 | import 'package:routemaster/routemaster.dart'; 9 | 10 | class LoginPage extends StatelessWidget { 11 | const LoginPage({Key? key}) : super(key: key); 12 | 13 | @override 14 | Widget build(BuildContext context) { 15 | return const Scaffold( 16 | body: Center( 17 | child: _LoginForm(), 18 | ), 19 | ); 20 | } 21 | } 22 | 23 | class _LoginForm extends ConsumerStatefulWidget { 24 | const _LoginForm({Key? key}) : super(key: key); 25 | 26 | @override 27 | ConsumerState<_LoginForm> createState() => _LoginFormState(); 28 | } 29 | 30 | class _LoginFormState extends ConsumerState<_LoginForm> { 31 | final _formKey = GlobalKey(); 32 | final _emailTextEditingController = TextEditingController(); 33 | final _passwordTextEditingController = TextEditingController(); 34 | 35 | @override 36 | void dispose() { 37 | _emailTextEditingController.dispose(); 38 | _passwordTextEditingController.dispose(); 39 | super.dispose(); 40 | } 41 | 42 | Future _signIn() async { 43 | if (_formKey.currentState!.validate()) { 44 | await ref.read(LoginController.notifier).createSession( 45 | email: _emailTextEditingController.text, 46 | password: _passwordTextEditingController.text, 47 | ); 48 | } 49 | } 50 | 51 | @override 52 | Widget build(BuildContext context) { 53 | ref.errorControllerStateListener(context, LoginController.provider); 54 | return Padding( 55 | padding: const EdgeInsets.all(8.0), 56 | child: ConstrainedBox( 57 | constraints: BoxConstraints.loose(const Size.fromWidth(320)), 58 | child: Form( 59 | key: _formKey, 60 | child: Column( 61 | mainAxisAlignment: MainAxisAlignment.center, 62 | children: [ 63 | const Align( 64 | alignment: Alignment.centerLeft, 65 | child: Padding( 66 | padding: EdgeInsets.symmetric(vertical: 8.0), 67 | child: Text('Welcome to Google Docs clone! 👋🏻', 68 | style: TextStyle(fontWeight: FontWeight.bold)), 69 | ), 70 | ), 71 | const Align( 72 | alignment: Alignment.centerLeft, 73 | child: Padding( 74 | padding: EdgeInsets.symmetric(vertical: 8.0), 75 | child: Text( 76 | 'This is a Flutter app made with Appwrite and Riverpod ✍️', 77 | ), 78 | ), 79 | ), 80 | EmailTextField(controller: _emailTextEditingController), 81 | PasswordTextField(controller: _passwordTextEditingController), 82 | Padding( 83 | padding: const EdgeInsets.all(8.0), 84 | child: ElevatedButton( 85 | onPressed: _signIn, 86 | child: const Text('Sign in'), 87 | ), 88 | ), 89 | Text.rich( 90 | TextSpan( 91 | text: 'Don\'t have an account? ', 92 | children: [ 93 | TextSpan( 94 | text: 'Join now', 95 | style: const TextStyle(fontWeight: FontWeight.w600), 96 | recognizer: TapGestureRecognizer() 97 | ..onTap = () => 98 | Routemaster.of(context).push(AppRoutes.register), 99 | ) 100 | ], 101 | ), 102 | ), 103 | ], 104 | ), 105 | ), 106 | ), 107 | ); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: google_docs_clone 2 | description: A new Flutter project. 3 | 4 | # The following line prevents the package from being accidentally published to 5 | # pub.dev using `flutter pub publish`. This is preferred for private packages. 6 | publish_to: 'none' # Remove this line if you wish to publish to pub.dev 7 | 8 | # The following defines the version and build number for your application. 9 | # A version number is three numbers separated by dots, like 1.2.43 10 | # followed by an optional build number separated by a +. 11 | # Both the version and the builder number may be overridden in flutter 12 | # build by specifying --build-name and --build-number, respectively. 13 | # In Android, build-name is used as versionName while build-number used as versionCode. 14 | # Read more about Android versioning at https://developer.android.com/studio/publish/versioning 15 | # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. 16 | # Read more about iOS versioning at 17 | # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html 18 | version: 1.0.0+1 19 | 20 | environment: 21 | sdk: ">=2.16.1 <3.0.0" 22 | 23 | # Dependencies specify other packages that your package needs in order to work. 24 | # To automatically upgrade your package dependencies to the latest versions 25 | # consider running `flutter pub upgrade --major-versions`. Alternatively, 26 | # dependencies can be manually updated by changing the version numbers below to 27 | # the latest version available on pub.dev. To see which dependencies have newer 28 | # versions available, run `flutter pub outdated`. 29 | dependencies: 30 | flutter: 31 | sdk: flutter 32 | 33 | 34 | # The following adds the Cupertino Icons font to your application. 35 | # Use with the CupertinoIcons class for iOS style icons. 36 | cupertino_icons: ^1.0.2 37 | logging: ^1.0.2 38 | flutter_riverpod: ^1.0.3 39 | routemaster: ^0.9.5 40 | appwrite: ^4.0.1 41 | equatable: ^2.0.3 42 | flutter_quill: ^4.1.0 43 | uuid: ^3.0.6 44 | 45 | dev_dependencies: 46 | flutter_test: 47 | sdk: flutter 48 | 49 | # The "flutter_lints" package below contains a set of recommended lints to 50 | # encourage good coding practices. The lint set provided by the package is 51 | # activated in the `analysis_options.yaml` file located at the root of your 52 | # package. See that file for information about deactivating specific lint 53 | # rules and activating additional ones. 54 | flutter_lints: ^1.0.0 55 | 56 | # For information on the generic Dart part of this file, see the 57 | # following page: https://dart.dev/tools/pub/pubspec 58 | 59 | # The following section is specific to Flutter. 60 | flutter: 61 | 62 | # The following line ensures that the Material Icons font is 63 | # included with your application, so that you can use the icons in 64 | # the material Icons class. 65 | uses-material-design: true 66 | 67 | # To add assets to your application, add an assets section, like this: 68 | # assets: 69 | # - images/a_dot_burr.jpeg 70 | # - images/a_dot_ham.jpeg 71 | 72 | # An image asset can refer to one or more resolution-specific "variants", see 73 | # https://flutter.dev/assets-and-images/#resolution-aware. 74 | 75 | # For details regarding adding assets from package dependencies, see 76 | # https://flutter.dev/assets-and-images/#from-packages 77 | 78 | # To add custom fonts to your application, add a fonts section here, 79 | # in this "flutter" section. Each entry in this list should have a 80 | # "family" key with the font family name, and a "fonts" key with a 81 | # list giving the asset and other descriptors for the font. For 82 | # example: 83 | # fonts: 84 | # - family: Schyler 85 | # fonts: 86 | # - asset: fonts/Schyler-Regular.ttf 87 | # - asset: fonts/Schyler-Italic.ttf 88 | # style: italic 89 | # - family: Trajan Pro 90 | # fonts: 91 | # - asset: fonts/TrajanPro.ttf 92 | # - asset: fonts/TrajanPro_Bold.ttf 93 | # weight: 700 94 | # 95 | # For details regarding fonts from package dependencies, 96 | # see https://flutter.dev/custom-fonts/#from-packages 97 | -------------------------------------------------------------------------------- /lib/repositories/database_repository.dart: -------------------------------------------------------------------------------- 1 | import 'package:appwrite/appwrite.dart'; 2 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 | import 'package:google_docs_clone/app/constants.dart'; 4 | import 'package:google_docs_clone/app/providers.dart'; 5 | import 'package:google_docs_clone/app/utils.dart'; 6 | import 'package:google_docs_clone/models/models.dart'; 7 | import 'package:google_docs_clone/repositories/repository_exception.dart'; 8 | 9 | final _databaseRepositoryProvider = Provider((ref) { 10 | return DatabaseRepository(ref.read); 11 | }); 12 | 13 | class DatabaseRepository with RepositoryExceptionMixin { 14 | DatabaseRepository(this._read); 15 | 16 | final Reader _read; 17 | 18 | static Provider get provider => 19 | _databaseRepositoryProvider; 20 | 21 | Realtime get _realtime => _read(Dependency.realtime); 22 | 23 | Database get _database => _read(Dependency.database); 24 | 25 | Future createNewPage({ 26 | required String documentId, 27 | required String owner, 28 | }) async { 29 | return exceptionHandler( 30 | _createPageAndDelta(owner: owner, documentId: documentId)); 31 | } 32 | 33 | Future _createPageAndDelta({ 34 | required String documentId, 35 | required String owner, 36 | }) async { 37 | Future.wait([ 38 | _database.createDocument( 39 | collectionId: CollectionNames.pages, 40 | documentId: documentId, 41 | data: { 42 | 'owner': owner, 43 | 'title': null, 44 | 'content': null, 45 | }, 46 | ), 47 | _database.createDocument( 48 | collectionId: CollectionNames.delta, 49 | documentId: documentId, 50 | data: { 51 | 'delta': null, 52 | 'user': null, 53 | 'deviceId': null, 54 | }, 55 | ), 56 | ]); 57 | } 58 | 59 | Future getPage({ 60 | required String documentId, 61 | }) { 62 | return exceptionHandler(_getPage(documentId)); 63 | } 64 | 65 | Future _getPage(String documentId) async { 66 | final doc = await _database.getDocument( 67 | collectionId: CollectionNames.pages, 68 | documentId: documentId, 69 | ); 70 | return DocumentPageData.fromMap(doc.data); 71 | } 72 | 73 | Future> getAllPages(String userId) async { 74 | return exceptionHandler(_getAllPages(userId)); 75 | } 76 | 77 | Future> _getAllPages(String userId) async { 78 | final result = await _database.listDocuments( 79 | collectionId: CollectionNames.pages, 80 | queries: [Query.equal('owner', userId)], 81 | ); 82 | return result.documents.map((element) { 83 | return DocumentPageData.fromMap(element.data); 84 | }).toList(); 85 | } 86 | 87 | Future updatePage( 88 | {required String documentId, 89 | required DocumentPageData documentPage}) async { 90 | return exceptionHandler( 91 | _database.updateDocument( 92 | collectionId: CollectionNames.pages, 93 | documentId: documentId, 94 | data: documentPage.toMap(), 95 | ), 96 | ); 97 | } 98 | 99 | Future updateDelta({ 100 | required String pageId, 101 | required DeltaData deltaData, 102 | }) { 103 | return exceptionHandler( 104 | _database.updateDocument( 105 | collectionId: CollectionNames.delta, 106 | documentId: pageId, 107 | data: deltaData.toMap(), 108 | ), 109 | ); 110 | } 111 | 112 | RealtimeSubscription subscribeToPage({required String pageId}) { 113 | try { 114 | return _realtime 115 | .subscribe(['${CollectionNames.deltaDocumentsPath}.$pageId']); 116 | } on AppwriteException catch (e) { 117 | logger.warning(e.message, e); 118 | throw RepositoryException( 119 | message: e.message ?? 'An undefined error occured'); 120 | } on Exception catch (e, st) { 121 | logger.severe('Error subscribing to page changes', e, st); 122 | throw RepositoryException( 123 | message: 'Error subscribing to page changes', 124 | exception: e, 125 | stackTrace: st); 126 | } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | google_docs_clone 33 | 34 | 35 | 36 | 39 | 103 | 104 | 105 | -------------------------------------------------------------------------------- /lib/components/auth/register/register_page.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/gestures.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 4 | import 'package:google_docs_clone/app/navigation/routes.dart'; 5 | import 'package:google_docs_clone/app/utils.dart'; 6 | import 'package:google_docs_clone/components/auth/register/register_controller.dart'; 7 | import 'package:google_docs_clone/components/auth/widgets/widgets.dart'; 8 | import 'package:routemaster/routemaster.dart'; 9 | 10 | class RegisterPage extends StatelessWidget { 11 | const RegisterPage({Key? key}) : super(key: key); 12 | 13 | @override 14 | Widget build(BuildContext context) { 15 | return const Scaffold( 16 | body: Center( 17 | child: _RegisterForm(), 18 | ), 19 | ); 20 | } 21 | } 22 | 23 | class _RegisterForm extends ConsumerStatefulWidget { 24 | const _RegisterForm({Key? key}) : super(key: key); 25 | 26 | @override 27 | ConsumerState<_RegisterForm> createState() => _RegisterFormState(); 28 | } 29 | 30 | class _RegisterFormState extends ConsumerState<_RegisterForm> { 31 | final _formKey = GlobalKey(); 32 | final _nameTextEditingController = TextEditingController(); 33 | final _emailTextEditingController = TextEditingController(); 34 | final _passwordTextEditingController = TextEditingController(); 35 | 36 | @override 37 | void dispose() { 38 | _nameTextEditingController.dispose(); 39 | _emailTextEditingController.dispose(); 40 | _passwordTextEditingController.dispose(); 41 | super.dispose(); 42 | } 43 | 44 | Future createAccount() async { 45 | if (_formKey.currentState!.validate()) { 46 | await ref.read(RegisterController.notifier).create( 47 | email: _emailTextEditingController.text, 48 | password: _passwordTextEditingController.text, 49 | name: _nameTextEditingController.text, 50 | ); 51 | } 52 | } 53 | 54 | @override 55 | Widget build(BuildContext context) { 56 | ref.errorControllerStateListener(context, RegisterController.provider); 57 | return ConstrainedBox( 58 | constraints: BoxConstraints.loose(const Size.fromWidth(320)), 59 | child: Padding( 60 | padding: const EdgeInsets.all(8.0), 61 | child: Form( 62 | key: _formKey, 63 | child: Column( 64 | mainAxisAlignment: MainAxisAlignment.center, 65 | children: [ 66 | const Align( 67 | alignment: Alignment.centerLeft, 68 | child: Padding( 69 | padding: EdgeInsets.symmetric(vertical: 8.0), 70 | child: Text('Create an account 🚀', 71 | style: TextStyle(fontWeight: FontWeight.bold)), 72 | ), 73 | ), 74 | const Align( 75 | alignment: Alignment.centerLeft, 76 | child: Padding( 77 | padding: EdgeInsets.symmetric(vertical: 8.0), 78 | child: Text( 79 | 'Unlock the power of Flutter and Appwrite.', 80 | ), 81 | ), 82 | ), 83 | NameTextField(controller: _nameTextEditingController), 84 | EmailTextField(controller: _emailTextEditingController), 85 | PasswordTextField(controller: _passwordTextEditingController), 86 | Padding( 87 | padding: const EdgeInsets.all(8.0), 88 | child: ElevatedButton( 89 | onPressed: createAccount, 90 | child: const Text('Create'), 91 | ), 92 | ), 93 | Text.rich( 94 | TextSpan( 95 | text: 'Already have an account? ', 96 | children: [ 97 | TextSpan( 98 | text: 'Sign in', 99 | style: const TextStyle(fontWeight: FontWeight.w600), 100 | recognizer: TapGestureRecognizer() 101 | ..onTap = 102 | () => Routemaster.of(context).push(AppRoutes.login), 103 | ) 104 | ], 105 | ), 106 | ), 107 | ], 108 | ), 109 | ), 110 | ), 111 | ); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /lib/components/document/state/document_controller.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:convert'; 3 | 4 | import 'package:flutter/material.dart'; 5 | import 'package:flutter_quill/flutter_quill.dart'; 6 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 7 | import 'package:google_docs_clone/app/providers.dart'; 8 | import 'package:google_docs_clone/app/utils.dart'; 9 | import 'package:google_docs_clone/components/document/state/document_state.dart'; 10 | import 'package:google_docs_clone/models/models.dart'; 11 | import 'package:google_docs_clone/repositories/repositories.dart'; 12 | import 'package:uuid/uuid.dart'; 13 | 14 | final _documentProvider = 15 | StateNotifierProvider.family( 16 | (ref, documentId) => DocumentController( 17 | ref.read, 18 | documentId: documentId, 19 | ), 20 | ); 21 | 22 | class DocumentController extends StateNotifier { 23 | final _deviceId = const Uuid().v4(); 24 | 25 | /// Debounce [Timer] for automatic saving. 26 | Timer? _debounce; 27 | 28 | DocumentController(this._read, {required String documentId}) 29 | : super( 30 | DocumentState(id: documentId), 31 | ) { 32 | _setupDocument(); 33 | _setupListeners(); 34 | } 35 | 36 | late final StreamSubscription? documentListener; 37 | late final StreamSubscription? realtimeListener; 38 | 39 | static StateNotifierProviderFamily 40 | get provider => _documentProvider; 41 | 42 | static AlwaysAliveProviderBase notifier( 43 | String documentId) => 44 | provider(documentId).notifier; 45 | 46 | final Reader _read; 47 | 48 | Future _setupDocument() async { 49 | try { 50 | final docPageData = await _read(Repository.database).getPage( 51 | documentId: state.id, 52 | ); 53 | 54 | late final Document quillDoc; 55 | if (docPageData.content.isEmpty) { 56 | quillDoc = Document()..insert(0, ''); 57 | } else { 58 | quillDoc = Document.fromDelta(docPageData.content); 59 | } 60 | 61 | final controller = QuillController( 62 | document: quillDoc, 63 | selection: const TextSelection.collapsed(offset: 0), 64 | ); 65 | 66 | state = state.copyWith( 67 | documentPageData: docPageData, 68 | quillDocument: quillDoc, 69 | quillController: controller, 70 | isSavedRemotely: true, 71 | ); 72 | 73 | state.quillController?.addListener(_quillControllerUpdate); 74 | 75 | documentListener = state.quillDocument?.changes.listen((event) { 76 | final delta = event.item2; 77 | final source = event.item3; 78 | 79 | if (source != ChangeSource.LOCAL) { 80 | return; 81 | } 82 | _broadcastDeltaUpdate(delta); 83 | }); 84 | } on RepositoryException catch (e) { 85 | state = state.copyWith(error: AppError(message: e.message)); 86 | } 87 | } 88 | 89 | Future _setupListeners() async { 90 | final subscription = 91 | _read(Repository.database).subscribeToPage(pageId: state.id); 92 | realtimeListener = subscription.stream.listen( 93 | (event) { 94 | final dId = event.payload['deviceId']; 95 | if (_deviceId != dId) { 96 | final delta = Delta.fromJson(jsonDecode(event.payload['delta'])); 97 | state.quillController?.compose( 98 | delta, 99 | state.quillController?.selection ?? 100 | const TextSelection.collapsed(offset: 0), 101 | ChangeSource.REMOTE, 102 | ); 103 | } 104 | }, 105 | ); 106 | } 107 | 108 | Future _broadcastDeltaUpdate(Delta delta) async { 109 | _read(Repository.database).updateDelta( 110 | pageId: state.id, 111 | deltaData: DeltaData( 112 | user: _read(AppState.auth).user!.$id, 113 | delta: jsonEncode(delta.toJson()), 114 | deviceId: _deviceId, 115 | ), 116 | ); 117 | } 118 | 119 | void _quillControllerUpdate() { 120 | state = state.copyWith(isSavedRemotely: false); 121 | _debounceSave(); 122 | } 123 | 124 | void _debounceSave({Duration duration = const Duration(seconds: 2)}) { 125 | if (_debounce?.isActive ?? false) _debounce?.cancel(); 126 | _debounce = Timer(duration, () { 127 | saveDocumentImmediately(); 128 | }); 129 | } 130 | 131 | void setTitle(String title) { 132 | state = state.copyWith( 133 | documentPageData: state.documentPageData?.copyWith( 134 | title: title, 135 | ), 136 | isSavedRemotely: false, 137 | ); 138 | _debounceSave(duration: const Duration(milliseconds: 500)); 139 | } 140 | 141 | Future saveDocumentImmediately() async { 142 | logger.info('Saving document: ${state.id}'); 143 | if (state.documentPageData == null || state.quillDocument == null) { 144 | logger.severe('Cannot save document, doc state is empty'); 145 | state = state.copyWith( 146 | error: AppError(message: 'Cannot save document, state is empty'), 147 | ); 148 | } 149 | state = state.copyWith( 150 | documentPageData: state.documentPageData! 151 | .copyWith(content: state.quillDocument!.toDelta()), 152 | ); 153 | try { 154 | await _read(Repository.database).updatePage( 155 | documentId: state.id, 156 | documentPage: state.documentPageData!, 157 | ); 158 | state = state.copyWith(isSavedRemotely: true); 159 | } on RepositoryException catch (e) { 160 | state = state.copyWith( 161 | error: AppError(message: e.message), 162 | isSavedRemotely: false, 163 | ); 164 | } 165 | } 166 | 167 | @override 168 | void dispose() { 169 | documentListener?.cancel(); 170 | realtimeListener?.cancel(); 171 | state.quillController?.removeListener(_quillControllerUpdate); 172 | super.dispose(); 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /lib/components/document/widgets/all_documents_popup.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_quill/flutter_quill.dart' hide Text; 3 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 4 | import 'package:google_docs_clone/app/navigation/routes.dart'; 5 | import 'package:google_docs_clone/app/providers.dart'; 6 | import 'package:google_docs_clone/app/state/state.dart'; 7 | import 'package:google_docs_clone/models/models.dart'; 8 | import 'package:routemaster/routemaster.dart'; 9 | 10 | final _documentsProvider = FutureProvider>((ref) { 11 | return ref 12 | .read(Repository.database) 13 | .getAllPages(ref.read(AuthService.provider).user!.$id); 14 | }); 15 | 16 | class AllDocumentsPopup extends ConsumerStatefulWidget { 17 | const AllDocumentsPopup({Key? key}) : super(key: key); 18 | 19 | @override 20 | ConsumerState createState() => 21 | _AllDocumentsPopopState(); 22 | } 23 | 24 | class _AllDocumentsPopopState extends ConsumerState { 25 | @override 26 | Widget build(BuildContext context) { 27 | final documents = ref.watch(_documentsProvider); 28 | return documents.when( 29 | data: ((data) => _DocumentsGrid(documents: data)), 30 | loading: () => const Center( 31 | child: CircularProgressIndicator(), 32 | ), 33 | error: (e, st) => const Center( 34 | child: Text('Could not load data'), 35 | ), 36 | ); 37 | } 38 | } 39 | 40 | class _DocumentsGrid extends StatelessWidget { 41 | const _DocumentsGrid({ 42 | Key? key, 43 | required this.documents, 44 | }) : super(key: key); 45 | 46 | final List documents; 47 | 48 | @override 49 | Widget build(BuildContext context) { 50 | return LayoutBuilder(builder: (context, constraints) { 51 | int crossAxisCount = 6; 52 | double verticalPadding = 32; 53 | final maxWidth = constraints.maxWidth; 54 | 55 | if (maxWidth < 1400) { 56 | verticalPadding = 28; 57 | crossAxisCount = 5; 58 | } 59 | if (maxWidth < 1100) { 60 | verticalPadding = 18; 61 | crossAxisCount = 4; 62 | } 63 | if (maxWidth < 900) { 64 | verticalPadding = 12; 65 | crossAxisCount = 3; 66 | } 67 | if (maxWidth < 500) { 68 | verticalPadding = 8; 69 | crossAxisCount = 2; 70 | } 71 | return Column( 72 | children: [ 73 | Padding( 74 | padding: const EdgeInsets.all(24.0), 75 | child: Center( 76 | child: Text( 77 | 'Your Documents', 78 | overflow: TextOverflow.ellipsis, 79 | style: Theme.of(context).textTheme.headline4, 80 | ), 81 | ), 82 | ), 83 | const Divider(), 84 | Expanded( 85 | child: Padding( 86 | padding: const EdgeInsets.symmetric(horizontal: 8.0), 87 | child: Scrollbar( 88 | child: GridView.builder( 89 | gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( 90 | crossAxisCount: crossAxisCount, 91 | ), 92 | itemCount: documents.length, 93 | itemBuilder: (context, index) { 94 | final documentPage = documents[index]; 95 | late final Document quillDoc; 96 | if (documentPage.content.isEmpty) { 97 | quillDoc = Document()..insert(0, ''); 98 | } else { 99 | quillDoc = Document.fromDelta(documentPage.content); 100 | } 101 | late final String content; 102 | if (!quillDoc.isEmpty()) { 103 | content = quillDoc.toPlainText(); 104 | } else { 105 | content = 'Empty'; 106 | } 107 | final title = (documentPage.title.isEmpty) 108 | ? 'No title' 109 | : documentPage.title; 110 | 111 | return Padding( 112 | padding: const EdgeInsets.all(8.0), 113 | child: Container( 114 | decoration: BoxDecoration( 115 | borderRadius: BorderRadius.circular(10), 116 | border: Border.all(), 117 | boxShadow: [ 118 | BoxShadow( 119 | color: Colors.black.withOpacity(0.1), 120 | offset: const Offset(1, 3), 121 | spreadRadius: 3, 122 | blurRadius: 5, 123 | ), 124 | ], 125 | ), 126 | child: Material( 127 | borderRadius: BorderRadius.circular(10), 128 | child: InkWell( 129 | borderRadius: BorderRadius.circular(10), 130 | onTap: () { 131 | Navigator.of(context).pop(); 132 | Routemaster.of(context).push( 133 | '${AppRoutes.document}/${documentPage.id}'); 134 | }, 135 | child: Column( 136 | children: [ 137 | Padding( 138 | padding: EdgeInsets.symmetric( 139 | horizontal: 16.0, 140 | vertical: verticalPadding, 141 | ), 142 | child: Text( 143 | title, 144 | overflow: TextOverflow.ellipsis, 145 | style: Theme.of(context) 146 | .textTheme 147 | .headlineSmall, 148 | ), 149 | ), 150 | Padding( 151 | padding: const EdgeInsets.all(16.0), 152 | child: Text( 153 | content, 154 | overflow: TextOverflow.ellipsis, 155 | style: Theme.of(context).textTheme.caption, 156 | ), 157 | ), 158 | ], 159 | ), 160 | ), 161 | ), 162 | ), 163 | ); 164 | }, 165 | ), 166 | ), 167 | ), 168 | ), 169 | ], 170 | ); 171 | }); 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Google-Docs Like Clone With Flutter Web and Appwrite

2 | 3 |

4 | This project is a demo application showing how to create a rich text editing experience, similar to Google Docs, using Flutter, Appwrite, and Flutter-Quill. This is meant to serve as a demo on how to use these tools. Please see the video tutorial for a step-by-step walkthrough. Beginner friendly 😎 5 |
6 | 7 | ### Realtime Changes - Collaboration 8 | Collaborate with other users on the same document in real-time. 9 | 10 |

11 | Realtime changes demos 12 |

13 | 14 | ### Create and Open Documents 15 | Easily create and re-open documents. Everything is saved to your Appwrite database. 16 | 17 |

18 | Create a new document demo 19 |

20 | 21 | ### Authentication - Sign-in and Create New Accounts 22 | Easy authentication using Appwrite. 23 | 24 |

25 | Registration demo 26 |

27 | 28 | 29 | ## Packages 30 | Packages used in this project. 31 | 32 | built with appwrite logo 33 | 34 | ### Backend: Appwrite 35 | [Appwrite](https://appwrite.io/?utm_source=influencer&utm_medium=homepage&utm_campaign=funwithflutter) is an open-source alternative to Firebase and makes it possible to easily integrate authentication and add a backend database for your application. Appwrite also makes it possible to add real-time listeners to your database quickly. 36 | 37 | - [Appwrite Docs](https://appwrite.io/docs?utm_source=influencer&utm_medium=docs&utm_campaign=funwithflutter) 38 | - [Appwrite Github](https://github.com/appwrite/?utm_source=influencer&utm_medium=github&utm_campaign=funwithflutter) 39 | - [Appwrite Discord](https://discord.com/invite/GSeTUeA?utm_source=influencer&utm_medium=discord&utm_campaign=funwithflutter) 40 | 41 | > Appwrite sponsored this project and tutorial 42 | 43 | ### Rich Text Editor: Flutter-Quill 44 | [FlutterQuill](https://pub.dev/packages/flutter_quill) is a rich text editor and a [Quill](https://quilljs.com/docs/formats) component for [Flutter](https://github.com/flutter/flutter). This package makes it easy to sync incremental changes to other editors (real-time changes). 45 | 46 | **Honorable Mentions**: [SuperEditor](https://superlist.com/SuperEditor/) 47 | 48 | ### State Management: Riverpod 49 | [Riverpod](https://riverpod.dev/) is an excellent choice for a state management solution in your Flutter application, and this tutorial demonstrates multiple scenarios where Riverpod truly shines. If you've not used it before, this project may change your mind. The video tutorial highlights numerous excellent features and demos how to structure and organize your providers. 50 | 51 | ### Routing: Routemaster 52 | [Routemaster](https://pub.dev/packages/routemaster) simplifies the complexity of Flutter's 2.0 Navigator. This project creates two separate route maps: 53 | - Authenticated routes 54 | - Not authenticated routes 55 | 56 | Riverpod, and the authentication state from Appwrite, determine which routes to allow. 57 | 58 | ### Other Packages 59 | [Equatable](https://pub.dev/packages/equatable): A Dart package that helps implement value-based equality without needing to explicitly override `==` and `hashCode`. 60 | - [UUID](https://pub.dev/packages/uuid): Simple, fast generation of [RFC4122](https://www.ietf.org/rfc/rfc4122.txt) UUIDs. 61 | - [Logging](https://pub.dev/packages/logging): Provides APIs for debugging and error logging. 62 | 63 | ## Tutorial 64 | The project is split into multiple sections to make the tutorial easy to follow. 65 | 66 | ### Video Tutorial 67 | For a complete step-by-step guide, see: https://youtu.be/0_GJ1w_iG44 68 | 69 | ### Tutorial Sections 70 | For the most up-to-date code: see the [master branch](https://github.com/funwithflutter/google-docs-clone-flutter/tree/master). 71 | 72 | The tutorial sections are extracted as dedicated branches on Github, meaning you can easily follow along and always have the latest code before starting new sections. 73 | 74 | 0. Intro: [video](https://www.youtube.com/watch?v=0_GJ1w_iG44&t=0s) 75 | 1. Base: [branch](https://github.com/funwithflutter/google-docs-clone-flutter/tree/01-base) and [video](https://www.youtube.com/watch?v=0_GJ1w_iG44&t=239s) 76 | 2. Setup Riverpod: [branch](https://github.com/funwithflutter/google-docs-clone-flutter/tree/02-setup_riverpod) and [video](https://www.youtube.com/watch?v=0_GJ1w_iG44&t=308s) 77 | 3. Setup Routemaster: [branch](https://github.com/funwithflutter/google-docs-clone-flutter/tree/03-setup_routemaster) and [video](https://www.youtube.com/watch?v=0_GJ1w_iG44&t=398s) 78 | 4. Appwrite Setup: [branch](https://github.com/funwithflutter/google-docs-clone-flutter/tree/04-appwrite_setup) and [video](https://www.youtube.com/watch?v=0_GJ1w_iG44&t=697s) 79 | 5. Create Authentication Repository: [branch](https://github.com/funwithflutter/google-docs-clone-flutter/tree/05-auth_repository) and [video](https://www.youtube.com/watch?v=0_GJ1w_iG44&t=1039s) 80 | 6. Create Auth State Service: [branch](https://github.com/funwithflutter/google-docs-clone-flutter/tree/06-auth_state_service) and [video](https://www.youtube.com/watch?v=0_GJ1w_iG44&t=1473s) 81 | 7. Create Login and Register Page: [branch](https://github.com/funwithflutter/google-docs-clone-flutter/tree/07-login_and_register_page) and [video](https://www.youtube.com/watch?v=0_GJ1w_iG44&t=2099s) 82 | 8. Add Logged in Routes: [branch](https://github.com/funwithflutter/google-docs-clone-flutter/tree/08-add_logged_in_routes) and [video](https://www.youtube.com/watch?v=0_GJ1w_iG44&t=2702s) 83 | 9. Add Document UI and State: [branch](https://github.com/funwithflutter/google-docs-clone-flutter/tree/09-add_document_ui_and_state) and [video](https://www.youtube.com/watch?v=0_GJ1w_iG44&t=3118s) 84 | 10. Create Documents: [branch](https://github.com/funwithflutter/google-docs-clone-flutter/tree/10-create_documents) and [video](https://www.youtube.com/watch?v=0_GJ1w_iG44&t=4067s) 85 | 11. Update and Save Documents: [branch](https://github.com/funwithflutter/google-docs-clone-flutter/tree/11-update_and_save_documents) and [video](https://www.youtube.com/watch?v=0_GJ1w_iG44&t=4695s) 86 | 12. Add Realtime Changes: [branch](https://github.com/funwithflutter/google-docs-clone-flutter/tree/12-realtime_changes) and [video](https://www.youtube.com/watch?v=0_GJ1w_iG44&t=5722s) 87 | 13. List All Documents: [branch](https://github.com/funwithflutter/google-docs-clone-flutter/tree/13-list_all_documents) and [video](https://www.youtube.com/watch?v=0_GJ1w_iG44&t=6525s) 88 | 14. What's Next?: [video](https://www.youtube.com/watch?v=0_GJ1w_iG44&t=7011s) 89 | -------------------------------------------------------------------------------- /lib/components/document/widgets/menu_bar.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | const _menuTextStyle = TextStyle(fontSize: 20); 4 | 5 | class MenuBar extends StatelessWidget { 6 | const MenuBar({ 7 | Key? key, 8 | this.leading = const [], 9 | this.trailing = const [], 10 | this.newDocumentPressed, 11 | this.openDocumentsPressed, 12 | this.signOutPressed, 13 | this.membersPressed, 14 | this.inviteMembersPressed, 15 | this.copyPressed, 16 | this.cutPressed, 17 | this.redoPressed, 18 | this.undoPressed, 19 | }) : super(key: key); 20 | 21 | final List leading; 22 | final List trailing; 23 | 24 | final VoidCallback? newDocumentPressed; 25 | final VoidCallback? openDocumentsPressed; 26 | final VoidCallback? signOutPressed; 27 | final VoidCallback? membersPressed; 28 | final VoidCallback? inviteMembersPressed; 29 | 30 | final VoidCallback? copyPressed; 31 | final VoidCallback? cutPressed; 32 | final VoidCallback? redoPressed; 33 | final VoidCallback? undoPressed; 34 | 35 | @override 36 | Widget build(BuildContext context) { 37 | return Container( 38 | decoration: const BoxDecoration( 39 | border: Border( 40 | bottom: BorderSide( 41 | color: Colors.grey, 42 | width: 0.4, 43 | ), 44 | ), 45 | ), 46 | child: Padding( 47 | padding: const EdgeInsets.all(8.0), 48 | child: Column( 49 | crossAxisAlignment: CrossAxisAlignment.start, 50 | children: [ 51 | Row( 52 | children: [ 53 | ...leading, 54 | const Spacer(), 55 | ...trailing, 56 | ], 57 | ), 58 | Row( 59 | children: [ 60 | _FileMenuButton( 61 | newDocumentPressed: newDocumentPressed, 62 | openDocumentsPressed: openDocumentsPressed, 63 | signOutPressed: signOutPressed, 64 | membersPressed: membersPressed, 65 | inviteMembersPressed: inviteMembersPressed, 66 | ), 67 | _EditMenuButton( 68 | copyPressed: copyPressed, 69 | cutPressed: cutPressed, 70 | redoPressed: redoPressed, 71 | undoPressed: undoPressed, 72 | ), 73 | ], 74 | ), 75 | ], 76 | ), 77 | ), 78 | ); 79 | } 80 | } 81 | 82 | class _FileMenuButton extends StatelessWidget { 83 | const _FileMenuButton({ 84 | Key? key, 85 | this.newDocumentPressed, 86 | this.openDocumentsPressed, 87 | this.signOutPressed, 88 | this.membersPressed, 89 | this.inviteMembersPressed, 90 | }) : super(key: key); 91 | 92 | final VoidCallback? newDocumentPressed; 93 | final VoidCallback? openDocumentsPressed; 94 | final VoidCallback? signOutPressed; 95 | final VoidCallback? membersPressed; 96 | final VoidCallback? inviteMembersPressed; 97 | 98 | @override 99 | Widget build(BuildContext context) { 100 | return PopupMenuButton( 101 | offset: const Offset(0, 40), 102 | itemBuilder: (context) => [ 103 | PopupMenuItem( 104 | child: const PopUpMenuTile( 105 | isActive: true, 106 | title: 'New Document', 107 | ), 108 | onTap: () { 109 | newDocumentPressed?.call(); 110 | }, 111 | ), 112 | PopupMenuItem( 113 | child: const PopUpMenuTile( 114 | isActive: true, 115 | title: 'Open', 116 | ), 117 | onTap: () { 118 | openDocumentsPressed?.call(); 119 | }, 120 | ), 121 | PopupMenuItem( 122 | child: const PopUpMenuTile( 123 | isActive: true, 124 | icon: Icons.logout_rounded, 125 | title: 'Sign out', 126 | ), 127 | onTap: () { 128 | signOutPressed?.call(); 129 | }, 130 | ), 131 | PopupMenuItem( 132 | child: const PopUpMenuTile( 133 | icon: Icons.group, 134 | title: 'Members', 135 | ), 136 | onTap: () { 137 | membersPressed?.call(); 138 | }, 139 | ), 140 | PopupMenuItem( 141 | child: const PopUpMenuTile( 142 | icon: Icons.person_add, 143 | title: 'Invite members', 144 | ), 145 | onTap: () { 146 | inviteMembersPressed?.call(); 147 | }, 148 | ), 149 | ], 150 | child: const Padding( 151 | padding: EdgeInsets.all(8.0), 152 | child: Text( 153 | 'File', 154 | style: _menuTextStyle, 155 | ), 156 | ), 157 | ); 158 | } 159 | } 160 | 161 | class _EditMenuButton extends StatelessWidget { 162 | const _EditMenuButton({ 163 | Key? key, 164 | this.undoPressed, 165 | this.redoPressed, 166 | this.cutPressed, 167 | this.copyPressed, 168 | this.pastePressed, 169 | }) : super(key: key); 170 | 171 | final VoidCallback? undoPressed; 172 | final VoidCallback? redoPressed; 173 | final VoidCallback? cutPressed; 174 | final VoidCallback? copyPressed; 175 | final VoidCallback? pastePressed; 176 | 177 | @override 178 | Widget build(BuildContext context) { 179 | return PopupMenuButton( 180 | offset: const Offset(0, 40), 181 | itemBuilder: (context) => [ 182 | PopupMenuItem( 183 | value: 0, 184 | child: const PopUpMenuTile( 185 | isActive: true, 186 | icon: Icons.undo, 187 | title: 'Undo', 188 | ), 189 | onTap: () { 190 | undoPressed?.call(); 191 | }, 192 | ), 193 | PopupMenuItem( 194 | value: 1, 195 | child: const PopUpMenuTile( 196 | isActive: true, 197 | icon: Icons.redo, 198 | title: 'Redo', 199 | ), 200 | onTap: () { 201 | redoPressed?.call(); 202 | }, 203 | ), 204 | PopupMenuItem( 205 | value: 2, 206 | child: const PopUpMenuTile( 207 | icon: Icons.cut, 208 | title: 'Cut', 209 | ), 210 | onTap: () { 211 | cutPressed?.call(); 212 | }, 213 | ), 214 | PopupMenuItem( 215 | value: 3, 216 | child: const PopUpMenuTile( 217 | icon: Icons.copy, 218 | title: 'Copy', 219 | ), 220 | onTap: () { 221 | copyPressed?.call(); 222 | }, 223 | ), 224 | PopupMenuItem( 225 | value: 3, 226 | child: const PopUpMenuTile( 227 | icon: Icons.paste, 228 | title: 'Paste', 229 | ), 230 | onTap: () { 231 | pastePressed?.call(); 232 | }, 233 | ), 234 | ], 235 | child: const Padding( 236 | padding: EdgeInsets.all(8.0), 237 | child: Text('Edit', style: _menuTextStyle), 238 | ), 239 | ); 240 | } 241 | } 242 | 243 | class PopUpMenuTile extends StatelessWidget { 244 | const PopUpMenuTile( 245 | {Key? key, required this.title, this.icon, this.isActive = false}) 246 | : super(key: key); 247 | final IconData? icon; 248 | final String title; 249 | final bool isActive; 250 | 251 | @override 252 | Widget build(BuildContext context) { 253 | return Row( 254 | mainAxisSize: MainAxisSize.max, 255 | mainAxisAlignment: MainAxisAlignment.start, 256 | children: [ 257 | Icon(icon), 258 | const SizedBox( 259 | width: 8, 260 | ), 261 | Text( 262 | title, 263 | ), 264 | ], 265 | ); 266 | } 267 | } 268 | -------------------------------------------------------------------------------- /lib/components/document/document_page.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_quill/flutter_quill.dart' hide Text; 3 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 4 | import 'package:google_docs_clone/app/app.dart'; 5 | import 'package:google_docs_clone/app/navigation/routes.dart'; 6 | import 'package:google_docs_clone/app/providers.dart'; 7 | import 'package:google_docs_clone/components/document/state/document_controller.dart'; 8 | import 'package:google_docs_clone/components/document/widgets/widgets.dart'; 9 | import 'package:routemaster/routemaster.dart'; 10 | import 'package:tuple/tuple.dart'; 11 | 12 | final _quillControllerProvider = 13 | Provider.family((ref, id) { 14 | final test = ref.watch(DocumentController.provider(id)); 15 | return test.quillController; 16 | }); 17 | 18 | class DocumentPage extends ConsumerWidget { 19 | const DocumentPage({ 20 | Key? key, 21 | required this.documentId, 22 | }) : super(key: key); 23 | 24 | final String documentId; 25 | 26 | @override 27 | Widget build(BuildContext context, WidgetRef ref) { 28 | return Scaffold( 29 | body: Column( 30 | mainAxisAlignment: MainAxisAlignment.spaceBetween, 31 | children: [ 32 | MenuBar( 33 | leading: [_TitleTextEditor(documentId: documentId)], 34 | trailing: [_IsSavedWidget(documentId: documentId)], 35 | newDocumentPressed: () { 36 | Routemaster.of(context).push(AppRoutes.newDocument); 37 | }, 38 | signOutPressed: () { 39 | ref.read(AppState.auth.notifier).signOut(); 40 | }, 41 | openDocumentsPressed: () { 42 | Future.delayed(const Duration(seconds: 0), () { 43 | showDialog( 44 | context: context, 45 | builder: (context) { 46 | return Dialog( 47 | child: ConstrainedBox( 48 | constraints: 49 | BoxConstraints.loose(const Size(1400, 700)), 50 | child: const AllDocumentsPopup(), 51 | ), 52 | ); 53 | }); 54 | }); 55 | }, 56 | ), 57 | _Toolbar(documentId: documentId), 58 | Expanded( 59 | child: _DocumentEditorWidget( 60 | documentId: documentId, 61 | ), 62 | ), 63 | ], 64 | ), 65 | ); 66 | } 67 | } 68 | 69 | final _documentTitleProvider = Provider.family((ref, id) { 70 | return ref.watch(DocumentController.provider(id)).documentPageData?.title; 71 | }); 72 | 73 | class _TitleTextEditor extends ConsumerStatefulWidget { 74 | const _TitleTextEditor({ 75 | Key? key, 76 | required this.documentId, 77 | }) : super(key: key); 78 | final String documentId; 79 | 80 | @override 81 | ConsumerState createState() => 82 | __TitleTextEditorState(); 83 | } 84 | 85 | class __TitleTextEditorState extends ConsumerState<_TitleTextEditor> { 86 | final TextEditingController _textEditingController = TextEditingController(); 87 | 88 | @override 89 | void dispose() { 90 | _textEditingController.dispose(); 91 | super.dispose(); 92 | } 93 | 94 | @override 95 | Widget build(BuildContext context) { 96 | ref.listen( 97 | _documentTitleProvider(widget.documentId), 98 | (String? previousValue, String? newValue) { 99 | if (newValue != _textEditingController.text) { 100 | _textEditingController.text = newValue ?? ''; 101 | } 102 | }, 103 | ); 104 | return Padding( 105 | padding: const EdgeInsets.all(8.0), 106 | child: IntrinsicWidth( 107 | child: TextField( 108 | controller: _textEditingController, 109 | onChanged: 110 | ref.read(DocumentController.notifier(widget.documentId)).setTitle, 111 | decoration: const InputDecoration( 112 | hintText: 'Untitled document', 113 | enabledBorder: OutlineInputBorder( 114 | borderSide: BorderSide(color: Colors.transparent, width: 3), 115 | ), 116 | focusedBorder: OutlineInputBorder( 117 | borderSide: BorderSide(color: Colors.blue, width: 3), 118 | ), 119 | ), 120 | style: const TextStyle(fontSize: 26, fontWeight: FontWeight.w400), 121 | ), 122 | ), 123 | ); 124 | } 125 | } 126 | 127 | final _isSavedRemotelyProvider = Provider.family((ref, id) { 128 | return ref.watch(DocumentController.provider(id)).isSavedRemotely; 129 | }); 130 | 131 | class _IsSavedWidget extends ConsumerWidget { 132 | const _IsSavedWidget({Key? key, required this.documentId}) : super(key: key); 133 | 134 | final String documentId; 135 | 136 | @override 137 | Widget build(BuildContext context, WidgetRef ref) { 138 | var isSaved = ref.watch(_isSavedRemotelyProvider(documentId)); 139 | return Padding( 140 | padding: const EdgeInsets.all(8.0), 141 | child: Text( 142 | 'Saved', 143 | style: TextStyle( 144 | fontSize: 18, 145 | color: isSaved ? AppColors.secondary : Colors.grey, 146 | ), 147 | ), 148 | ); 149 | } 150 | } 151 | 152 | class _DocumentEditorWidget extends ConsumerStatefulWidget { 153 | const _DocumentEditorWidget({ 154 | Key? key, 155 | required this.documentId, 156 | }) : super(key: key); 157 | 158 | final String documentId; 159 | 160 | @override 161 | ConsumerState createState() => 162 | __DocumentEditorState(); 163 | } 164 | 165 | class __DocumentEditorState extends ConsumerState<_DocumentEditorWidget> { 166 | final FocusNode _focusNode = FocusNode(); 167 | final ScrollController _scrollController = ScrollController(); 168 | 169 | @override 170 | Widget build(BuildContext context) { 171 | final quillController = 172 | ref.watch(_quillControllerProvider(widget.documentId)); 173 | 174 | if (quillController == null) { 175 | return const Center(child: CircularProgressIndicator()); 176 | } 177 | 178 | return GestureDetector( 179 | onTap: () => _focusNode.requestFocus(), 180 | child: RawKeyboardListener( 181 | focusNode: FocusNode(), 182 | onKey: (event) { 183 | if (event.data.isControlPressed && event.character == 'b' || 184 | event.data.isMetaPressed && event.character == 'b') { 185 | if (quillController 186 | .getSelectionStyle() 187 | .attributes 188 | .keys 189 | .contains('bold')) { 190 | quillController 191 | .formatSelection(Attribute.clone(Attribute.bold, null)); 192 | } else { 193 | quillController.formatSelection(Attribute.bold); 194 | } 195 | } 196 | }, 197 | child: Padding( 198 | padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 24), 199 | child: Card( 200 | elevation: 7, 201 | child: Padding( 202 | padding: const EdgeInsets.all(86.0), 203 | child: QuillEditor( 204 | controller: quillController, 205 | scrollController: _scrollController, 206 | scrollable: true, 207 | focusNode: _focusNode, 208 | autoFocus: false, 209 | readOnly: false, 210 | expands: false, 211 | padding: EdgeInsets.zero, 212 | customStyles: DefaultStyles( 213 | h1: DefaultTextBlockStyle( 214 | const TextStyle( 215 | fontSize: 36, 216 | color: Colors.black, 217 | height: 1.15, 218 | fontWeight: FontWeight.w600, 219 | ), 220 | const Tuple2(32, 28), 221 | const Tuple2(0, 0), 222 | null, 223 | ), 224 | h2: DefaultTextBlockStyle( 225 | const TextStyle( 226 | fontSize: 30, 227 | color: Colors.black, 228 | fontWeight: FontWeight.w600, 229 | ), 230 | const Tuple2(28, 24), 231 | const Tuple2(0, 0), 232 | null, 233 | ), 234 | h3: DefaultTextBlockStyle( 235 | TextStyle( 236 | fontSize: 24, 237 | color: Colors.grey[800], 238 | fontWeight: FontWeight.w600, 239 | ), 240 | const Tuple2(18, 14), 241 | const Tuple2(0, 0), 242 | null, 243 | ), 244 | paragraph: DefaultTextBlockStyle( 245 | const TextStyle( 246 | fontSize: 20, 247 | color: Colors.black, 248 | fontWeight: FontWeight.w400, 249 | ), 250 | const Tuple2(2, 0), 251 | const Tuple2(0, 0), 252 | null, 253 | ), 254 | ), 255 | embedBuilder: _defaultEmbedBuilderWeb, 256 | ), 257 | ), 258 | ), 259 | ), 260 | ), 261 | ); 262 | } 263 | 264 | Widget _defaultEmbedBuilderWeb(BuildContext context, 265 | QuillController controller, Embed node, bool readOnly) { 266 | throw UnimplementedError( 267 | 'Embeddable type "${node.value.type}" is not supported by default ' 268 | 'embed builder of QuillEditor. You must pass your own builder function ' 269 | 'to embedBuilder property of QuillEditor or QuillField widgets.', 270 | ); 271 | } 272 | } 273 | 274 | class _Toolbar extends ConsumerWidget { 275 | const _Toolbar({ 276 | Key? key, 277 | required this.documentId, 278 | }) : super(key: key); 279 | 280 | final String documentId; 281 | 282 | @override 283 | Widget build(BuildContext context, WidgetRef ref) { 284 | final quillController = ref.watch(_quillControllerProvider(documentId)); 285 | 286 | if (quillController == null) { 287 | return const Center(child: CircularProgressIndicator()); 288 | } 289 | 290 | return QuillToolbar.basic( 291 | controller: quillController, 292 | iconTheme: const QuillIconTheme( 293 | iconSelectedFillColor: AppColors.secondary, 294 | ), 295 | multiRowsDisplay: false, 296 | showAlignmentButtons: true, 297 | ); 298 | } 299 | } 300 | -------------------------------------------------------------------------------- /lib/app/navigation/transition_page.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/foundation.dart'; 2 | import 'package:flutter/material.dart'; 3 | 4 | /// A transition for a page pop or push animation. 5 | abstract class PageTransition { 6 | /// Initialize a transition for a page pop or push animation. 7 | const PageTransition(); 8 | 9 | /// A builder that configures the animation. 10 | PageTransitionsBuilder get transitionsBuilder; 11 | 12 | /// How long this transition animation lasts. 13 | Duration get duration; 14 | 15 | /// A transition with no animation. 16 | static const PageTransition none = _NoPageTransition(); 17 | 18 | /// The default fade upwards transition used on Android. 19 | static const PageTransition fadeUpwards = _FadeUpwardsPageTransition(); 20 | 21 | /// The default slide-in transition used on iOS. 22 | static const PageTransition cupertino = _CupertinoPageTransition(); 23 | 24 | /// A zoom transition matching the one used on Android 10. 25 | static const PageTransition zoom = _ZoomPageTransition(); 26 | 27 | /// Returns the default page transition for the given [platform]. 28 | static PageTransition platformDefault(TargetPlatform platform) { 29 | if (kIsWeb) { 30 | return PageTransition.none; 31 | } 32 | 33 | switch (platform) { 34 | case TargetPlatform.android: 35 | case TargetPlatform.linux: 36 | case TargetPlatform.windows: 37 | case TargetPlatform.fuchsia: 38 | return PageTransition.fadeUpwards; 39 | 40 | case TargetPlatform.iOS: 41 | case TargetPlatform.macOS: 42 | return PageTransition.cupertino; 43 | } 44 | } 45 | } 46 | 47 | class _NoPageTransition extends PageTransition { 48 | const _NoPageTransition(); 49 | 50 | @override 51 | final Duration duration = 52 | // Workaround for https://github.com/flutter/flutter/issues/86604 53 | const Duration(microseconds: 1); 54 | 55 | @override 56 | final PageTransitionsBuilder transitionsBuilder = 57 | const _NoPageTransitionBuilder(); 58 | } 59 | 60 | class _NoPageTransitionBuilder extends PageTransitionsBuilder { 61 | const _NoPageTransitionBuilder(); 62 | 63 | @override 64 | Widget buildTransitions( 65 | PageRoute route, 66 | BuildContext context, 67 | Animation animation, 68 | Animation secondaryAnimation, 69 | Widget child, 70 | ) { 71 | return child; 72 | } 73 | } 74 | 75 | class _CupertinoPageTransition extends PageTransition { 76 | const _CupertinoPageTransition(); 77 | 78 | @override 79 | final Duration duration = const Duration(milliseconds: 400); 80 | 81 | @override 82 | final PageTransitionsBuilder transitionsBuilder = 83 | const CupertinoPageTransitionsBuilder(); 84 | } 85 | 86 | class _FadeUpwardsPageTransition extends PageTransition { 87 | const _FadeUpwardsPageTransition(); 88 | 89 | @override 90 | final Duration duration = const Duration(milliseconds: 300); 91 | 92 | @override 93 | final PageTransitionsBuilder transitionsBuilder = 94 | const FadeUpwardsPageTransitionsBuilder(); 95 | } 96 | 97 | class _ZoomPageTransition extends PageTransition { 98 | const _ZoomPageTransition(); 99 | 100 | @override 101 | final Duration duration = const Duration(milliseconds: 300); 102 | 103 | @override 104 | final PageTransitionsBuilder transitionsBuilder = 105 | const ZoomPageTransitionsBuilder(); 106 | } 107 | 108 | /// A page that can use separate push and pop animations. 109 | /// 110 | /// [pushTransition] and [popAnimation] can use one of the built-in transitions: 111 | /// 112 | /// * [PageTransition.none] - an immediate transition without any animation. 113 | /// * [PageTransition.fadeUpwards] - the default Android fade-up animation. 114 | /// * [PageTransition.cupertino] - the default iOS slide-in animation. 115 | /// * [PageTransition.zoom] - a zoom animation used on Android 10. 116 | /// 117 | /// Alternatively you can subclass [PageTransition] to create your own custom 118 | /// animation. 119 | /// 120 | /// If [pushTransition] or [popAnimation] are null, the platform default 121 | /// transition is used. This is the Cupertino animation on iOS and macOS, and 122 | /// the fade upwards animation on all other platforms. 123 | class TransitionPage extends TransitionBuilderPage { 124 | /// Initialize a transition page. 125 | /// 126 | /// If [pushTransition] or [popAnimation] are null, the platform default 127 | /// transition is used. This is the Cupertino animation on iOS and macOS, and 128 | /// the fade upwards animation on all other platforms. 129 | const TransitionPage({ 130 | required this.child, 131 | this.pushTransition, 132 | this.popTransition, 133 | this.maintainState = true, 134 | this.fullscreenDialog = false, 135 | this.opaque = true, 136 | LocalKey? key, 137 | String? name, 138 | Object? arguments, 139 | String? restorationId, 140 | }) : super( 141 | child: child, 142 | key: key, 143 | name: name, 144 | arguments: arguments, 145 | restorationId: restorationId, 146 | ); 147 | 148 | /// Configures the transition animation used when this page is pushed. 149 | /// 150 | /// This can be set to one of the default built-in transitions: 151 | /// 152 | /// * [PageTransition.none] - an immediate transition without any animation. 153 | /// * [PageTransition.fadeUpwards] - the default Android fade-up animation. 154 | /// * [PageTransition.cupertino] - the default iOS slide-in animation. 155 | /// * [PageTransition.zoom] - a zoom animation used on Android 10. 156 | /// 157 | /// Alternatively you can subclass [PageTransition] to create your own custom 158 | /// animation. 159 | /// 160 | /// If this value is null, the platform default transition is used. 161 | final PageTransition? pushTransition; 162 | 163 | /// Configures the transition animation used when this page is popped. 164 | /// 165 | /// This can be set to one of the default built-in transitions: 166 | /// 167 | /// * [PageTransition.none] - an immediate transition without any animation. 168 | /// * [PageTransition.fadeUpwards] - the default Android fade-up animation. 169 | /// * [PageTransition.cupertino] - the default iOS slide-in animation. 170 | /// * [PageTransition.zoom] - a zoom animation used on Android 10. 171 | /// 172 | /// Alternatively you can subclass [PageTransition] to create your own custom 173 | /// animation. 174 | /// 175 | /// If this value is null, the platform default transition is used. 176 | final PageTransition? popTransition; 177 | 178 | @override 179 | PageTransition buildPushTransition(BuildContext context) { 180 | if (pushTransition == null) { 181 | return PageTransition.platformDefault(Theme.of(context).platform); 182 | } 183 | 184 | return pushTransition!; 185 | } 186 | 187 | @override 188 | PageTransition buildPopTransition(BuildContext context) { 189 | if (popTransition == null) { 190 | return PageTransition.platformDefault(Theme.of(context).platform); 191 | } 192 | 193 | return popTransition!; 194 | } 195 | 196 | /// The content to be shown in the [Route] created by this page. 197 | @override 198 | final Widget child; 199 | 200 | /// {@macro flutter.widgets.ModalRoute.maintainState} 201 | @override 202 | final bool maintainState; 203 | 204 | /// {@macro flutter.widgets.PageRoute.fullscreenDialog} 205 | @override 206 | final bool fullscreenDialog; 207 | 208 | /// {@macro flutter.widgets.TransitionRoute.opaque} 209 | @override 210 | final bool opaque; 211 | } 212 | 213 | /// A page that can be subclassed to provide push and pop animations. 214 | /// 215 | /// When a page is pushed, [buildPushTransition] is called, and the returned 216 | /// transition is used to animate the page onto the screen. 217 | /// 218 | /// When a page is popped, [buildPopTransition] is called, and the returned 219 | /// transition is used to animate the page off the screen. 220 | abstract class TransitionBuilderPage extends Page { 221 | /// Initialize a page that provides separate push and pop animations. 222 | const TransitionBuilderPage({ 223 | required this.child, 224 | this.maintainState = true, 225 | this.fullscreenDialog = false, 226 | this.opaque = true, 227 | LocalKey? key, 228 | String? name, 229 | Object? arguments, 230 | String? restorationId, 231 | }) : super( 232 | key: key, 233 | name: name, 234 | arguments: arguments, 235 | restorationId: restorationId, 236 | ); 237 | 238 | /// Called when this page is pushed, returns a [PageTransition] to configure 239 | /// the push animation. 240 | /// 241 | /// Return `PageTransition.none` for an immediate push with no animation. 242 | PageTransition buildPushTransition(BuildContext context); 243 | 244 | /// Called when this page is popped, returns a [PageTransition] to configure 245 | /// the pop animation. 246 | /// 247 | /// Return `PageTransition.none` for an immediate pop with no animation. 248 | PageTransition buildPopTransition(BuildContext context); 249 | 250 | /// The content to be shown in the [Route] created by this page. 251 | final Widget child; 252 | 253 | /// {@macro flutter.widgets.ModalRoute.maintainState} 254 | final bool maintainState; 255 | 256 | /// {@macro flutter.widgets.PageRoute.fullscreenDialog} 257 | final bool fullscreenDialog; 258 | 259 | /// {@macro flutter.widgets.TransitionRoute.opaque} 260 | final bool opaque; 261 | 262 | @override 263 | Route createRoute(BuildContext context) { 264 | return TransitionBuilderPageRoute(page: this); 265 | } 266 | } 267 | 268 | /// The route created by by [TransitionBuilderPage], which delegates push and 269 | /// pop transition animations to that page. 270 | class TransitionBuilderPageRoute extends PageRoute { 271 | /// Initialize a route which delegates push and pop transition animations to 272 | /// the provided [page]. 273 | TransitionBuilderPageRoute({ 274 | required TransitionBuilderPage page, 275 | }) : super(settings: page); 276 | 277 | TransitionBuilderPage get _page => settings as TransitionBuilderPage; 278 | 279 | /// This value is not used. 280 | /// 281 | /// The actual durations are provides by the [PageTransition] objects. 282 | @override 283 | Duration get transitionDuration => Duration.zero; 284 | 285 | @override 286 | Color? get barrierColor => null; 287 | 288 | @override 289 | String? get barrierLabel => null; 290 | 291 | @override 292 | Widget buildPage( 293 | BuildContext context, 294 | Animation animation, 295 | Animation secondaryAnimation, 296 | ) { 297 | return Semantics( 298 | scopesRoute: true, 299 | explicitChildNodes: true, 300 | child: _page.child, 301 | ); 302 | } 303 | 304 | @override 305 | bool didPop(T? result) { 306 | final transition = _page.buildPopTransition(navigator!.context); 307 | controller!.reverseDuration = transition.duration; 308 | return super.didPop(result); 309 | } 310 | 311 | @override 312 | TickerFuture didPush() { 313 | final transition = _page.buildPushTransition(navigator!.context); 314 | controller!.duration = transition.duration; 315 | return super.didPush(); 316 | } 317 | 318 | @override 319 | Widget buildTransitions(BuildContext context, Animation animation, 320 | Animation secondaryAnimation, Widget child) { 321 | final isPopping = controller!.status == AnimationStatus.reverse; 322 | 323 | // If the push is complete we build the pop transition. 324 | // This is so cupertino back user gesture will work, even if a cupertino 325 | // transition wasn't used to show this page. 326 | final pushIsComplete = controller!.status == AnimationStatus.completed; 327 | 328 | final transition = 329 | (isPopping || pushIsComplete || navigator!.userGestureInProgress) 330 | ? _page.buildPopTransition(navigator!.context) 331 | : _page.buildPushTransition(navigator!.context); 332 | 333 | return transition.transitionsBuilder 334 | .buildTransitions(this, context, animation, secondaryAnimation, child); 335 | } 336 | 337 | @override 338 | bool get maintainState => _page.maintainState; 339 | 340 | @override 341 | bool get fullscreenDialog => _page.fullscreenDialog; 342 | 343 | @override 344 | bool get opaque => _page.opaque; 345 | 346 | @override 347 | String get debugLabel => '${super.debugLabel}(${_page.name})'; 348 | } 349 | -------------------------------------------------------------------------------- /pubspec.lock: -------------------------------------------------------------------------------- 1 | # Generated by pub 2 | # See https://dart.dev/tools/pub/glossary#lockfile 3 | packages: 4 | _fe_analyzer_shared: 5 | dependency: transitive 6 | description: 7 | name: _fe_analyzer_shared 8 | url: "https://pub.dartlang.org" 9 | source: hosted 10 | version: "38.0.0" 11 | analyzer: 12 | dependency: transitive 13 | description: 14 | name: analyzer 15 | url: "https://pub.dartlang.org" 16 | source: hosted 17 | version: "3.4.1" 18 | appwrite: 19 | dependency: "direct main" 20 | description: 21 | name: appwrite 22 | url: "https://pub.dartlang.org" 23 | source: hosted 24 | version: "4.0.1" 25 | args: 26 | dependency: transitive 27 | description: 28 | name: args 29 | url: "https://pub.dartlang.org" 30 | source: hosted 31 | version: "2.3.0" 32 | async: 33 | dependency: transitive 34 | description: 35 | name: async 36 | url: "https://pub.dartlang.org" 37 | source: hosted 38 | version: "2.8.2" 39 | boolean_selector: 40 | dependency: transitive 41 | description: 42 | name: boolean_selector 43 | url: "https://pub.dartlang.org" 44 | source: hosted 45 | version: "2.1.0" 46 | characters: 47 | dependency: transitive 48 | description: 49 | name: characters 50 | url: "https://pub.dartlang.org" 51 | source: hosted 52 | version: "1.2.0" 53 | charcode: 54 | dependency: transitive 55 | description: 56 | name: charcode 57 | url: "https://pub.dartlang.org" 58 | source: hosted 59 | version: "1.3.1" 60 | clock: 61 | dependency: transitive 62 | description: 63 | name: clock 64 | url: "https://pub.dartlang.org" 65 | source: hosted 66 | version: "1.1.0" 67 | collection: 68 | dependency: transitive 69 | description: 70 | name: collection 71 | url: "https://pub.dartlang.org" 72 | source: hosted 73 | version: "1.15.0" 74 | convert: 75 | dependency: transitive 76 | description: 77 | name: convert 78 | url: "https://pub.dartlang.org" 79 | source: hosted 80 | version: "3.0.1" 81 | cookie_jar: 82 | dependency: transitive 83 | description: 84 | name: cookie_jar 85 | url: "https://pub.dartlang.org" 86 | source: hosted 87 | version: "3.0.1" 88 | cross_file: 89 | dependency: transitive 90 | description: 91 | name: cross_file 92 | url: "https://pub.dartlang.org" 93 | source: hosted 94 | version: "0.3.2" 95 | crypto: 96 | dependency: transitive 97 | description: 98 | name: crypto 99 | url: "https://pub.dartlang.org" 100 | source: hosted 101 | version: "3.0.1" 102 | csslib: 103 | dependency: transitive 104 | description: 105 | name: csslib 106 | url: "https://pub.dartlang.org" 107 | source: hosted 108 | version: "0.17.1" 109 | cupertino_icons: 110 | dependency: "direct main" 111 | description: 112 | name: cupertino_icons 113 | url: "https://pub.dartlang.org" 114 | source: hosted 115 | version: "1.0.4" 116 | device_info_plus: 117 | dependency: transitive 118 | description: 119 | name: device_info_plus 120 | url: "https://pub.dartlang.org" 121 | source: hosted 122 | version: "3.2.2" 123 | device_info_plus_linux: 124 | dependency: transitive 125 | description: 126 | name: device_info_plus_linux 127 | url: "https://pub.dartlang.org" 128 | source: hosted 129 | version: "2.1.1" 130 | device_info_plus_macos: 131 | dependency: transitive 132 | description: 133 | name: device_info_plus_macos 134 | url: "https://pub.dartlang.org" 135 | source: hosted 136 | version: "2.2.2" 137 | device_info_plus_platform_interface: 138 | dependency: transitive 139 | description: 140 | name: device_info_plus_platform_interface 141 | url: "https://pub.dartlang.org" 142 | source: hosted 143 | version: "2.3.0+1" 144 | device_info_plus_web: 145 | dependency: transitive 146 | description: 147 | name: device_info_plus_web 148 | url: "https://pub.dartlang.org" 149 | source: hosted 150 | version: "2.1.0" 151 | device_info_plus_windows: 152 | dependency: transitive 153 | description: 154 | name: device_info_plus_windows 155 | url: "https://pub.dartlang.org" 156 | source: hosted 157 | version: "2.1.1" 158 | diff_match_patch: 159 | dependency: transitive 160 | description: 161 | name: diff_match_patch 162 | url: "https://pub.dartlang.org" 163 | source: hosted 164 | version: "0.4.1" 165 | equatable: 166 | dependency: "direct main" 167 | description: 168 | name: equatable 169 | url: "https://pub.dartlang.org" 170 | source: hosted 171 | version: "2.0.3" 172 | fake_async: 173 | dependency: transitive 174 | description: 175 | name: fake_async 176 | url: "https://pub.dartlang.org" 177 | source: hosted 178 | version: "1.2.0" 179 | ffi: 180 | dependency: transitive 181 | description: 182 | name: ffi 183 | url: "https://pub.dartlang.org" 184 | source: hosted 185 | version: "1.1.2" 186 | file: 187 | dependency: transitive 188 | description: 189 | name: file 190 | url: "https://pub.dartlang.org" 191 | source: hosted 192 | version: "6.1.2" 193 | flutter: 194 | dependency: "direct main" 195 | description: flutter 196 | source: sdk 197 | version: "0.0.0" 198 | flutter_colorpicker: 199 | dependency: transitive 200 | description: 201 | name: flutter_colorpicker 202 | url: "https://pub.dartlang.org" 203 | source: hosted 204 | version: "1.0.3" 205 | flutter_inappwebview: 206 | dependency: transitive 207 | description: 208 | name: flutter_inappwebview 209 | url: "https://pub.dartlang.org" 210 | source: hosted 211 | version: "5.3.2" 212 | flutter_keyboard_visibility: 213 | dependency: transitive 214 | description: 215 | name: flutter_keyboard_visibility 216 | url: "https://pub.dartlang.org" 217 | source: hosted 218 | version: "5.2.0" 219 | flutter_keyboard_visibility_platform_interface: 220 | dependency: transitive 221 | description: 222 | name: flutter_keyboard_visibility_platform_interface 223 | url: "https://pub.dartlang.org" 224 | source: hosted 225 | version: "2.0.0" 226 | flutter_keyboard_visibility_web: 227 | dependency: transitive 228 | description: 229 | name: flutter_keyboard_visibility_web 230 | url: "https://pub.dartlang.org" 231 | source: hosted 232 | version: "2.0.0" 233 | flutter_lints: 234 | dependency: "direct dev" 235 | description: 236 | name: flutter_lints 237 | url: "https://pub.dartlang.org" 238 | source: hosted 239 | version: "1.0.4" 240 | flutter_plugin_android_lifecycle: 241 | dependency: transitive 242 | description: 243 | name: flutter_plugin_android_lifecycle 244 | url: "https://pub.dartlang.org" 245 | source: hosted 246 | version: "2.0.5" 247 | flutter_quill: 248 | dependency: "direct main" 249 | description: 250 | name: flutter_quill 251 | url: "https://pub.dartlang.org" 252 | source: hosted 253 | version: "4.1.0" 254 | flutter_riverpod: 255 | dependency: "direct main" 256 | description: 257 | name: flutter_riverpod 258 | url: "https://pub.dartlang.org" 259 | source: hosted 260 | version: "1.0.3" 261 | flutter_test: 262 | dependency: "direct dev" 263 | description: flutter 264 | source: sdk 265 | version: "0.0.0" 266 | flutter_web_auth: 267 | dependency: transitive 268 | description: 269 | name: flutter_web_auth 270 | url: "https://pub.dartlang.org" 271 | source: hosted 272 | version: "0.4.1" 273 | flutter_web_plugins: 274 | dependency: transitive 275 | description: flutter 276 | source: sdk 277 | version: "0.0.0" 278 | gallery_saver: 279 | dependency: transitive 280 | description: 281 | name: gallery_saver 282 | url: "https://pub.dartlang.org" 283 | source: hosted 284 | version: "2.3.2" 285 | gettext_parser: 286 | dependency: transitive 287 | description: 288 | name: gettext_parser 289 | url: "https://pub.dartlang.org" 290 | source: hosted 291 | version: "0.2.0" 292 | glob: 293 | dependency: transitive 294 | description: 295 | name: glob 296 | url: "https://pub.dartlang.org" 297 | source: hosted 298 | version: "2.0.2" 299 | html: 300 | dependency: transitive 301 | description: 302 | name: html 303 | url: "https://pub.dartlang.org" 304 | source: hosted 305 | version: "0.15.0" 306 | http: 307 | dependency: transitive 308 | description: 309 | name: http 310 | url: "https://pub.dartlang.org" 311 | source: hosted 312 | version: "0.13.4" 313 | http_parser: 314 | dependency: transitive 315 | description: 316 | name: http_parser 317 | url: "https://pub.dartlang.org" 318 | source: hosted 319 | version: "4.0.0" 320 | i18n_extension: 321 | dependency: transitive 322 | description: 323 | name: i18n_extension 324 | url: "https://pub.dartlang.org" 325 | source: hosted 326 | version: "4.2.1" 327 | image_picker: 328 | dependency: transitive 329 | description: 330 | name: image_picker 331 | url: "https://pub.dartlang.org" 332 | source: hosted 333 | version: "0.8.4+11" 334 | image_picker_for_web: 335 | dependency: transitive 336 | description: 337 | name: image_picker_for_web 338 | url: "https://pub.dartlang.org" 339 | source: hosted 340 | version: "2.1.6" 341 | image_picker_platform_interface: 342 | dependency: transitive 343 | description: 344 | name: image_picker_platform_interface 345 | url: "https://pub.dartlang.org" 346 | source: hosted 347 | version: "2.4.4" 348 | intl: 349 | dependency: transitive 350 | description: 351 | name: intl 352 | url: "https://pub.dartlang.org" 353 | source: hosted 354 | version: "0.17.0" 355 | js: 356 | dependency: transitive 357 | description: 358 | name: js 359 | url: "https://pub.dartlang.org" 360 | source: hosted 361 | version: "0.6.3" 362 | lints: 363 | dependency: transitive 364 | description: 365 | name: lints 366 | url: "https://pub.dartlang.org" 367 | source: hosted 368 | version: "1.0.1" 369 | logging: 370 | dependency: "direct main" 371 | description: 372 | name: logging 373 | url: "https://pub.dartlang.org" 374 | source: hosted 375 | version: "1.0.2" 376 | matcher: 377 | dependency: transitive 378 | description: 379 | name: matcher 380 | url: "https://pub.dartlang.org" 381 | source: hosted 382 | version: "0.12.11" 383 | material_color_utilities: 384 | dependency: transitive 385 | description: 386 | name: material_color_utilities 387 | url: "https://pub.dartlang.org" 388 | source: hosted 389 | version: "0.1.3" 390 | meta: 391 | dependency: transitive 392 | description: 393 | name: meta 394 | url: "https://pub.dartlang.org" 395 | source: hosted 396 | version: "1.7.0" 397 | package_config: 398 | dependency: transitive 399 | description: 400 | name: package_config 401 | url: "https://pub.dartlang.org" 402 | source: hosted 403 | version: "2.0.2" 404 | package_info_plus: 405 | dependency: transitive 406 | description: 407 | name: package_info_plus 408 | url: "https://pub.dartlang.org" 409 | source: hosted 410 | version: "1.3.0" 411 | package_info_plus_linux: 412 | dependency: transitive 413 | description: 414 | name: package_info_plus_linux 415 | url: "https://pub.dartlang.org" 416 | source: hosted 417 | version: "1.0.3" 418 | package_info_plus_macos: 419 | dependency: transitive 420 | description: 421 | name: package_info_plus_macos 422 | url: "https://pub.dartlang.org" 423 | source: hosted 424 | version: "1.3.0" 425 | package_info_plus_platform_interface: 426 | dependency: transitive 427 | description: 428 | name: package_info_plus_platform_interface 429 | url: "https://pub.dartlang.org" 430 | source: hosted 431 | version: "1.0.2" 432 | package_info_plus_web: 433 | dependency: transitive 434 | description: 435 | name: package_info_plus_web 436 | url: "https://pub.dartlang.org" 437 | source: hosted 438 | version: "1.0.4" 439 | package_info_plus_windows: 440 | dependency: transitive 441 | description: 442 | name: package_info_plus_windows 443 | url: "https://pub.dartlang.org" 444 | source: hosted 445 | version: "1.0.4" 446 | path: 447 | dependency: transitive 448 | description: 449 | name: path 450 | url: "https://pub.dartlang.org" 451 | source: hosted 452 | version: "1.8.0" 453 | path_provider: 454 | dependency: transitive 455 | description: 456 | name: path_provider 457 | url: "https://pub.dartlang.org" 458 | source: hosted 459 | version: "2.0.9" 460 | path_provider_android: 461 | dependency: transitive 462 | description: 463 | name: path_provider_android 464 | url: "https://pub.dartlang.org" 465 | source: hosted 466 | version: "2.0.12" 467 | path_provider_ios: 468 | dependency: transitive 469 | description: 470 | name: path_provider_ios 471 | url: "https://pub.dartlang.org" 472 | source: hosted 473 | version: "2.0.8" 474 | path_provider_linux: 475 | dependency: transitive 476 | description: 477 | name: path_provider_linux 478 | url: "https://pub.dartlang.org" 479 | source: hosted 480 | version: "2.1.5" 481 | path_provider_macos: 482 | dependency: transitive 483 | description: 484 | name: path_provider_macos 485 | url: "https://pub.dartlang.org" 486 | source: hosted 487 | version: "2.0.5" 488 | path_provider_platform_interface: 489 | dependency: transitive 490 | description: 491 | name: path_provider_platform_interface 492 | url: "https://pub.dartlang.org" 493 | source: hosted 494 | version: "2.0.3" 495 | path_provider_windows: 496 | dependency: transitive 497 | description: 498 | name: path_provider_windows 499 | url: "https://pub.dartlang.org" 500 | source: hosted 501 | version: "2.0.5" 502 | pedantic: 503 | dependency: transitive 504 | description: 505 | name: pedantic 506 | url: "https://pub.dartlang.org" 507 | source: hosted 508 | version: "1.11.1" 509 | photo_view: 510 | dependency: transitive 511 | description: 512 | name: photo_view 513 | url: "https://pub.dartlang.org" 514 | source: hosted 515 | version: "0.13.0" 516 | platform: 517 | dependency: transitive 518 | description: 519 | name: platform 520 | url: "https://pub.dartlang.org" 521 | source: hosted 522 | version: "3.1.0" 523 | plugin_platform_interface: 524 | dependency: transitive 525 | description: 526 | name: plugin_platform_interface 527 | url: "https://pub.dartlang.org" 528 | source: hosted 529 | version: "2.1.2" 530 | process: 531 | dependency: transitive 532 | description: 533 | name: process 534 | url: "https://pub.dartlang.org" 535 | source: hosted 536 | version: "4.2.4" 537 | pub_semver: 538 | dependency: transitive 539 | description: 540 | name: pub_semver 541 | url: "https://pub.dartlang.org" 542 | source: hosted 543 | version: "2.1.1" 544 | quiver: 545 | dependency: transitive 546 | description: 547 | name: quiver 548 | url: "https://pub.dartlang.org" 549 | source: hosted 550 | version: "3.0.1+1" 551 | riverpod: 552 | dependency: transitive 553 | description: 554 | name: riverpod 555 | url: "https://pub.dartlang.org" 556 | source: hosted 557 | version: "1.0.3" 558 | routemaster: 559 | dependency: "direct main" 560 | description: 561 | name: routemaster 562 | url: "https://pub.dartlang.org" 563 | source: hosted 564 | version: "0.9.5" 565 | sky_engine: 566 | dependency: transitive 567 | description: flutter 568 | source: sdk 569 | version: "0.0.99" 570 | source_span: 571 | dependency: transitive 572 | description: 573 | name: source_span 574 | url: "https://pub.dartlang.org" 575 | source: hosted 576 | version: "1.8.1" 577 | sprintf: 578 | dependency: transitive 579 | description: 580 | name: sprintf 581 | url: "https://pub.dartlang.org" 582 | source: hosted 583 | version: "6.0.0" 584 | stack_trace: 585 | dependency: transitive 586 | description: 587 | name: stack_trace 588 | url: "https://pub.dartlang.org" 589 | source: hosted 590 | version: "1.10.0" 591 | state_notifier: 592 | dependency: transitive 593 | description: 594 | name: state_notifier 595 | url: "https://pub.dartlang.org" 596 | source: hosted 597 | version: "0.7.2+1" 598 | stream_channel: 599 | dependency: transitive 600 | description: 601 | name: stream_channel 602 | url: "https://pub.dartlang.org" 603 | source: hosted 604 | version: "2.1.0" 605 | string_scanner: 606 | dependency: transitive 607 | description: 608 | name: string_scanner 609 | url: "https://pub.dartlang.org" 610 | source: hosted 611 | version: "1.1.0" 612 | string_validator: 613 | dependency: transitive 614 | description: 615 | name: string_validator 616 | url: "https://pub.dartlang.org" 617 | source: hosted 618 | version: "0.3.0" 619 | term_glyph: 620 | dependency: transitive 621 | description: 622 | name: term_glyph 623 | url: "https://pub.dartlang.org" 624 | source: hosted 625 | version: "1.2.0" 626 | test_api: 627 | dependency: transitive 628 | description: 629 | name: test_api 630 | url: "https://pub.dartlang.org" 631 | source: hosted 632 | version: "0.4.8" 633 | tuple: 634 | dependency: transitive 635 | description: 636 | name: tuple 637 | url: "https://pub.dartlang.org" 638 | source: hosted 639 | version: "2.0.0" 640 | typed_data: 641 | dependency: transitive 642 | description: 643 | name: typed_data 644 | url: "https://pub.dartlang.org" 645 | source: hosted 646 | version: "1.3.0" 647 | url_launcher: 648 | dependency: transitive 649 | description: 650 | name: url_launcher 651 | url: "https://pub.dartlang.org" 652 | source: hosted 653 | version: "6.0.20" 654 | url_launcher_android: 655 | dependency: transitive 656 | description: 657 | name: url_launcher_android 658 | url: "https://pub.dartlang.org" 659 | source: hosted 660 | version: "6.0.15" 661 | url_launcher_ios: 662 | dependency: transitive 663 | description: 664 | name: url_launcher_ios 665 | url: "https://pub.dartlang.org" 666 | source: hosted 667 | version: "6.0.15" 668 | url_launcher_linux: 669 | dependency: transitive 670 | description: 671 | name: url_launcher_linux 672 | url: "https://pub.dartlang.org" 673 | source: hosted 674 | version: "3.0.0" 675 | url_launcher_macos: 676 | dependency: transitive 677 | description: 678 | name: url_launcher_macos 679 | url: "https://pub.dartlang.org" 680 | source: hosted 681 | version: "3.0.0" 682 | url_launcher_platform_interface: 683 | dependency: transitive 684 | description: 685 | name: url_launcher_platform_interface 686 | url: "https://pub.dartlang.org" 687 | source: hosted 688 | version: "2.0.5" 689 | url_launcher_web: 690 | dependency: transitive 691 | description: 692 | name: url_launcher_web 693 | url: "https://pub.dartlang.org" 694 | source: hosted 695 | version: "2.0.9" 696 | url_launcher_windows: 697 | dependency: transitive 698 | description: 699 | name: url_launcher_windows 700 | url: "https://pub.dartlang.org" 701 | source: hosted 702 | version: "3.0.0" 703 | uuid: 704 | dependency: "direct main" 705 | description: 706 | name: uuid 707 | url: "https://pub.dartlang.org" 708 | source: hosted 709 | version: "3.0.6" 710 | vector_math: 711 | dependency: transitive 712 | description: 713 | name: vector_math 714 | url: "https://pub.dartlang.org" 715 | source: hosted 716 | version: "2.1.1" 717 | video_player: 718 | dependency: transitive 719 | description: 720 | name: video_player 721 | url: "https://pub.dartlang.org" 722 | source: hosted 723 | version: "2.3.0" 724 | video_player_android: 725 | dependency: transitive 726 | description: 727 | name: video_player_android 728 | url: "https://pub.dartlang.org" 729 | source: hosted 730 | version: "2.3.2" 731 | video_player_avfoundation: 732 | dependency: transitive 733 | description: 734 | name: video_player_avfoundation 735 | url: "https://pub.dartlang.org" 736 | source: hosted 737 | version: "2.3.1" 738 | video_player_platform_interface: 739 | dependency: transitive 740 | description: 741 | name: video_player_platform_interface 742 | url: "https://pub.dartlang.org" 743 | source: hosted 744 | version: "5.1.1" 745 | video_player_web: 746 | dependency: transitive 747 | description: 748 | name: video_player_web 749 | url: "https://pub.dartlang.org" 750 | source: hosted 751 | version: "2.0.7" 752 | watcher: 753 | dependency: transitive 754 | description: 755 | name: watcher 756 | url: "https://pub.dartlang.org" 757 | source: hosted 758 | version: "1.0.1" 759 | web_socket_channel: 760 | dependency: transitive 761 | description: 762 | name: web_socket_channel 763 | url: "https://pub.dartlang.org" 764 | source: hosted 765 | version: "2.1.0" 766 | win32: 767 | dependency: transitive 768 | description: 769 | name: win32 770 | url: "https://pub.dartlang.org" 771 | source: hosted 772 | version: "2.5.0" 773 | xdg_directories: 774 | dependency: transitive 775 | description: 776 | name: xdg_directories 777 | url: "https://pub.dartlang.org" 778 | source: hosted 779 | version: "0.2.0+1" 780 | yaml: 781 | dependency: transitive 782 | description: 783 | name: yaml 784 | url: "https://pub.dartlang.org" 785 | source: hosted 786 | version: "3.1.0" 787 | youtube_player_flutter: 788 | dependency: transitive 789 | description: 790 | name: youtube_player_flutter 791 | url: "https://pub.dartlang.org" 792 | source: hosted 793 | version: "8.0.0" 794 | sdks: 795 | dart: ">=2.16.1 <3.0.0" 796 | flutter: ">=2.10.0" 797 | --------------------------------------------------------------------------------