diff --git a/lib/controllers/memory_slide_controller.dart b/lib/controllers/memory_slide_controller.dart new file mode 100644 index 0000000..811d70f --- /dev/null +++ b/lib/controllers/memory_slide_controller.dart @@ -0,0 +1,50 @@ +import 'package:property_change_notifier/property_change_notifier.dart'; + +class MemorySlideController extends PropertyChangeNotifier { + final int memoryLength; + + MemorySlideController({ + required this.memoryLength, + }); + + int _index = 0; + bool _paused = false; + bool _done = false; + bool _completed = false; + + bool get paused => _paused; + bool get done => _done; + int get index => _index; + bool get completed => _completed; + + void setPaused(bool paused) { + _paused = paused; + notifyListeners('paused'); + } + + void setDone() { + _done = true; + notifyListeners('done'); + } + + void next() { + if (_index == memoryLength - 1) { + _completed = true; + notifyListeners('completed'); + } else { + _paused = false; + _done = false; + _index++; + notifyListeners(); + } + } + + void reset() { + _completed = false; + _paused = false; + _done = false; + _index = 0; + + notifyListeners(); + } +} diff --git a/lib/controllers/status_controller.dart b/lib/controllers/status_controller.dart new file mode 100644 index 0000000..2481e0e --- /dev/null +++ b/lib/controllers/status_controller.dart @@ -0,0 +1,29 @@ +import 'package:property_change_notifier/property_change_notifier.dart'; + +class StatusController extends PropertyChangeNotifier { + final Duration duration; + bool _isForwarding = false; + bool _done = false; + + bool get isForwarding => _isForwarding; + bool get done => _done; + + StatusController({ + this.duration = const Duration(seconds: 4), + }); + + void start() { + _isForwarding = true; + notifyListeners('isForwarding'); + } + + void stop() { + _isForwarding = false; + notifyListeners('isForwarding'); + } + + void setDone() { + _done = true; + notifyListeners('done'); + } +} diff --git a/lib/foreign_types/memory.dart b/lib/foreign_types/memory.dart new file mode 100644 index 0000000..422b87a --- /dev/null +++ b/lib/foreign_types/memory.dart @@ -0,0 +1,30 @@ +import 'package:share_location/enums.dart'; + +class Memory { + final String id; + final DateTime creationDate; + final String location; + final bool isPublic; + final String userID; + + const Memory({ + required this.id, + required this.creationDate, + required this.location, + required this.isPublic, + required this.userID, + }); + + static parse(Map jsonData) { + return Memory( + id: jsonData['id'], + creationDate: DateTime.parse(jsonData['created_at']), + location: jsonData['location'], + isPublic: jsonData['is_public'], + userID: jsonData['user'], + ); + } + + MemoryType get type => + location.split('.').last == 'jpg' ? MemoryType.photo : MemoryType.video; +} diff --git a/lib/screens/login_screen.dart b/lib/screens/login_screen.dart index cb7aad3..45312fe 100644 --- a/lib/screens/login_screen.dart +++ b/lib/screens/login_screen.dart @@ -29,49 +29,51 @@ class _LoginScreenState extends AuthState with Loadable { super.dispose(); } - Future doesEmailExist() async { - // TODO: SECURE PROFILE READ ACCESS TO ONLY ALLOW ACCESS TO EMAIL ADDRESSES - final response = await supabase - .from('profiles') - .select() - .match({'username': emailController.text.trim()}).execute(); + Future _signUp() async { + final response = await supabase.auth.signUp( + emailController.text.trim(), + passwordController.text, + ); - return response.data.isNotEmpty; + final error = response.error; + + if (error != null) { + throw Exception(error); + } + } + + Future _signIn() async { + final response = await supabase.auth.signIn( + email: emailController.text.trim(), + password: passwordController.text, + ); + + final error = response.error; + + if (error != null) { + throw Exception(error); + } } Future signIn() async { - if (await doesEmailExist()) { - // Login User - final response = await supabase.auth.signIn( - email: emailController.text.trim(), - password: passwordController.text, - ); - final error = response.error; + try { + await _signUp(); + } catch (error) { + try { + await _signIn(); + } catch (error) { + if (mounted) { + context.showErrorSnackBar(message: error.toString()); - if (mounted) { - if (error != null) { - context.showErrorSnackBar(message: error.message); - } else { emailController.clear(); passwordController.clear(); } + return; } - } else { - // Sign up User - final response = await supabase.auth.signUp( - emailController.text.trim(), - passwordController.text, - ); + } - final error = response.error; - - if (mounted) { - if (error != null) { - context.showErrorSnackBar(message: error.message); - } else { - Navigator.pushReplacementNamed(context, MainScreen.ID); - } - } + if (mounted) { + Navigator.pushReplacementNamed(context, MainScreen.ID); } } diff --git a/lib/screens/main_screen/permissions_required_page.dart b/lib/screens/main_screen/permissions_required_page.dart index 7fbb48e..06f39d3 100644 --- a/lib/screens/main_screen/permissions_required_page.dart +++ b/lib/screens/main_screen/permissions_required_page.dart @@ -62,7 +62,7 @@ class _PermissionsRequiredPageState extends State { ), const SizedBox(height: MEDIUM_SPACE), const Text( - 'Please grant permissions to use this app', + 'Please grant the following permissions to use this app', ), const SizedBox(height: LARGE_SPACE), if (hasDeniedForever) ...[ diff --git a/lib/widgets/memory.dart b/lib/widgets/memory.dart index bd92e2b..13a353d 100644 --- a/lib/widgets/memory.dart +++ b/lib/widgets/memory.dart @@ -7,6 +7,7 @@ import 'package:share_location/managers/file_manager.dart'; import 'package:share_location/utils/auth_required.dart'; import 'package:share_location/widgets/raw_memory_display.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:video_player/video_player.dart'; enum MemoryFetchStatus { preparing, @@ -16,21 +17,27 @@ enum MemoryFetchStatus { done, } -class Memory extends StatefulWidget { +class MemoryView extends StatefulWidget { final String location; final DateTime creationDate; + final bool loopVideo; + final void Function(VideoPlayerController)? onVideoControllerInitialized; + final VoidCallback? onFileDownloaded; - const Memory({ + const MemoryView({ Key? key, required this.location, required this.creationDate, + this.loopVideo = false, + this.onVideoControllerInitialized, + this.onFileDownloaded, }) : super(key: key); @override - State createState() => _MemoryState(); + State createState() => _MemoryViewState(); } -class _MemoryState extends AuthRequiredState { +class _MemoryViewState extends AuthRequiredState { late final User _user; MemoryFetchStatus status = MemoryFetchStatus.preparing; Uint8List? data; @@ -67,6 +74,10 @@ class _MemoryState extends AuthRequiredState { .single() .execute(); + if (!mounted) { + return; + } + if (response.data == null) { setState(() { status = MemoryFetchStatus.error; @@ -86,12 +97,24 @@ class _MemoryState extends AuthRequiredState { try { final fileData = await FileManager.downloadFile('memories', location); + if (!mounted) { + return; + } + setState(() { status = MemoryFetchStatus.done; data = fileData; type = memoryType; }); + + if (widget.onFileDownloaded != null) { + widget.onFileDownloaded!(); + } } catch (error) { + if (!mounted) { + return; + } + setState(() { status = MemoryFetchStatus.error; }); @@ -111,6 +134,8 @@ class _MemoryState extends AuthRequiredState { return RawMemoryDisplay( data: data!, type: type!, + loopVideo: widget.loopVideo, + onVideoControllerInitialized: widget.onVideoControllerInitialized, ); } diff --git a/lib/widgets/memory_page.dart b/lib/widgets/memory_page.dart new file mode 100644 index 0000000..7bf212d --- /dev/null +++ b/lib/widgets/memory_page.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:share_location/constants/spacing.dart'; +import 'package:share_location/controllers/memory_slide_controller.dart'; +import 'package:share_location/foreign_types/memory.dart'; +import 'package:share_location/widgets/memory_slide.dart'; + +class MemoryPage extends StatefulWidget { + final DateTime date; + final List memories; + final VoidCallback onPreviousTimeline; + final VoidCallback onNextTimeline; + + const MemoryPage({ + Key? key, + required this.date, + required this.memories, + required this.onPreviousTimeline, + required this.onNextTimeline, + }) : super(key: key); + + @override + State createState() => _MemoryPageState(); +} + +class _MemoryPageState extends State { + late final MemorySlideController controller; + + @override + void initState() { + super.initState(); + + controller = MemorySlideController(memoryLength: widget.memories.length); + controller.addListener(() { + if (controller.done) { + controller.next(); + // Force UI update + setState(() {}); + } + }, ['done']); + controller.addListener(() { + if (controller.completed) { + widget.onNextTimeline(); + } + }, ['completed']); + } + + @override + void dispose() { + controller.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTapDown: (_) { + controller.setPaused(true); + }, + onTapUp: (_) { + controller.setPaused(false); + }, + child: Stack( + fit: StackFit.expand, + children: [ + MemorySlide( + key: Key(controller.index.toString()), + controller: controller, + memory: widget.memories[controller.index], + ), + Padding( + padding: const EdgeInsets.only( + top: LARGE_SPACE, left: MEDIUM_SPACE, right: MEDIUM_SPACE), + child: Text( + DateFormat('dd. MMMM yyyy').format(widget.date), + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.headline1, + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/memory_slide.dart b/lib/widgets/memory_slide.dart new file mode 100644 index 0000000..b364e63 --- /dev/null +++ b/lib/widgets/memory_slide.dart @@ -0,0 +1,100 @@ +import 'package:flutter/material.dart'; +import 'package:share_location/controllers/memory_slide_controller.dart'; +import 'package:share_location/controllers/status_controller.dart'; +import 'package:share_location/enums.dart'; +import 'package:share_location/foreign_types/memory.dart'; +import 'package:share_location/widgets/status.dart'; + +import 'memory.dart'; + +const BAR_HEIGHT = 4.0; +const DEFAULT_IMAGE_DURATION = Duration(seconds: 5); + +class MemorySlide extends StatefulWidget { + final Memory memory; + final MemorySlideController controller; + + const MemorySlide({ + Key? key, + required this.memory, + required this.controller, + }) : super(key: key); + + @override + State createState() => _MemorySlideState(); +} + +class _MemorySlideState extends State + with TickerProviderStateMixin { + StatusController? controller; + + Duration? duration; + + @override + void initState() { + super.initState(); + + widget.controller.addListener(() { + if (!mounted) { + return; + } + + if (widget.controller.paused) { + controller?.stop(); + } else { + controller?.start(); + } + }); + } + + @override + void dispose() { + controller?.dispose(); + + super.dispose(); + } + + void initializeAnimation(final Duration duration) { + this.duration = duration; + + controller = StatusController( + duration: duration, + )..addListener(widget.controller.setDone, ['done']); + + setState(() {}); + } + + @override + Widget build(BuildContext context) { + return Status( + controller: controller, + child: MemoryView( + creationDate: widget.memory.creationDate, + location: widget.memory.location, + loopVideo: false, + onFileDownloaded: () { + if (widget.memory.type == MemoryType.photo) { + initializeAnimation(DEFAULT_IMAGE_DURATION); + } + }, + onVideoControllerInitialized: (controller) { + if (mounted) { + initializeAnimation(controller.value.duration); + + widget.controller.addListener(() { + if (!mounted) { + return; + } + + if (widget.controller.paused) { + controller.pause(); + } else { + controller.play(); + } + }); + } + }, + ), + ); + } +} diff --git a/lib/widgets/raw_memory_display.dart b/lib/widgets/raw_memory_display.dart index 11881ec..22f4aba 100644 --- a/lib/widgets/raw_memory_display.dart +++ b/lib/widgets/raw_memory_display.dart @@ -11,6 +11,7 @@ class RawMemoryDisplay extends StatefulWidget { final MemoryType type; final bool loopVideo; final String? filename; + final void Function(VideoPlayerController)? onVideoControllerInitialized; const RawMemoryDisplay({ Key? key, @@ -18,6 +19,7 @@ class RawMemoryDisplay extends StatefulWidget { required this.type, this.loopVideo = false, this.filename, + this.onVideoControllerInitialized, }) : super(key: key); @override @@ -60,6 +62,10 @@ class _RawMemoryDisplayState extends State { setState(() {}); videoController!.setLooping(widget.loopVideo); videoController!.play(); + + if (widget.onVideoControllerInitialized != null) { + widget.onVideoControllerInitialized!(videoController!); + } }); } diff --git a/lib/widgets/status.dart b/lib/widgets/status.dart new file mode 100644 index 0000000..7561363 --- /dev/null +++ b/lib/widgets/status.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; +import 'package:share_location/constants/spacing.dart'; +import 'package:share_location/controllers/status_controller.dart'; + +const BAR_HEIGHT = 4.0; + +class Status extends StatefulWidget { + final StatusController? controller; + final Widget child; + + const Status({ + Key? key, + required this.child, + this.controller, + }) : super(key: key); + + @override + State createState() => _StatusState(); +} + +class _StatusState extends State with TickerProviderStateMixin { + late final Animation animation; + AnimationController? animationController; + + @override + void didUpdateWidget(covariant Status oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.controller != null && animationController == null) { + initializeAnimation(); + } + } + + @override + void dispose() { + animationController?.dispose(); + + super.dispose(); + } + + void initializeAnimation() { + animationController = AnimationController( + duration: widget.controller!.duration, + vsync: this, + ); + animation = + Tween(begin: 0.0, end: 1.0).animate(animationController!) + ..addStatusListener((status) { + if (status == AnimationStatus.completed) { + widget.controller!.setDone(); + } + }); + + animationController!.forward(); + + widget.controller!.addListener(() { + if (widget.controller!.isForwarding) { + animationController!.forward(); + } else { + animationController!.stop(); + } + }); + } + + @override + Widget build(BuildContext context) { + return Stack( + fit: StackFit.expand, + children: [ + widget.child, + Positioned( + left: 0, + bottom: SMALL_SPACE, + width: MediaQuery.of(context).size.width, + height: BAR_HEIGHT, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: SMALL_SPACE), + child: (widget.controller == null) + ? ClipRRect( + borderRadius: BorderRadius.circular(HUGE_SPACE), + child: LinearProgressIndicator( + value: null, + valueColor: AlwaysStoppedAnimation( + Colors.white.withOpacity(.3)), + backgroundColor: Colors.white.withOpacity(0.1), + ), + ) + : AnimatedBuilder( + animation: animation, + builder: (_, __) => ClipRRect( + borderRadius: BorderRadius.circular(HUGE_SPACE), + child: LinearProgressIndicator( + value: animation.value, + valueColor: + const AlwaysStoppedAnimation(Colors.white), + backgroundColor: Colors.white.withOpacity(0.1), + ), + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/widgets/timeline_scroll.dart b/lib/widgets/timeline_scroll.dart index b00eb4f..7ed3b38 100644 --- a/lib/widgets/timeline_scroll.dart +++ b/lib/widgets/timeline_scroll.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:share_location/foreign_types/memory.dart'; import 'package:share_location/utils/loadable.dart'; -import 'package:share_location/widgets/memory.dart'; +import 'package:share_location/widgets/memory_page.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; final supabase = Supabase.instance.client; @@ -30,12 +32,32 @@ class _TimelineScrollState extends State with Loadable { .select() .order('created_at', ascending: false) .execute(); + final memories = List.from( + List>.from(response.data).map(Memory.parse)); + final timelineMapped = convertMemoriesToTimeline(memories); setState(() { - timeline = response.data; + timeline = timelineMapped; }); } + static Map> convertMemoriesToTimeline( + final List memories, + ) { + final map = >{}; + + for (final memory in memories) { + final date = DateFormat('yyyy-MM-dd').format(memory.creationDate); + if (map.containsKey(date)) { + map[date]!.add(memory); + } else { + map[date] = [memory]; + } + } + + return map; + } + @override Widget build(BuildContext context) { if (timeline == null) { @@ -49,13 +71,17 @@ class _TimelineScrollState extends State with Loadable { controller: pageController, scrollDirection: Axis.vertical, itemCount: timeline.length, - itemBuilder: (_, index) => Container( - width: MediaQuery.of(context).size.width, - height: MediaQuery.of(context).size.height, - child: Memory( - location: timeline[index]['location'], - creationDate: DateTime.parse(timeline[index]['created_at']), - ), + itemBuilder: (_, index) => MemoryPage( + date: DateTime.parse(timeline.keys.toList()[index]), + memories: timeline.values.toList()[index], + onNextTimeline: () { + pageController.nextPage( + duration: Duration(milliseconds: 500), curve: Curves.ease); + }, + onPreviousTimeline: () { + pageController.previousPage( + duration: Duration(milliseconds: 500), curve: Curves.ease); + }, ), ), ); diff --git a/pubspec.lock b/pubspec.lock index 70d9d84..b6f1a63 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -254,6 +254,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "4.0.1" + intl: + dependency: "direct main" + description: + name: intl + url: "https://pub.dartlang.org" + source: hosted + version: "0.17.0" js: dependency: transitive description: @@ -443,6 +450,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "4.2.4" + property_change_notifier: + dependency: "direct main" + description: + name: property_change_notifier + url: "https://pub.dartlang.org" + source: hosted + version: "0.3.0" quiver: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index a646a21..776a427 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -42,6 +42,8 @@ dependencies: uuid: ^3.0.6 video_player: ^2.4.6 path_provider: ^2.0.11 + intl: ^0.17.0 + property_change_notifier: ^0.3.0 dev_dependencies: flutter_test: