diff --git a/lib/constants/values.dart b/lib/constants/values.dart index c20ca67..aadf02e 100644 --- a/lib/constants/values.dart +++ b/lib/constants/values.dart @@ -2,6 +2,8 @@ import 'dart:collection'; const DURATION_INFINITY = Duration(days: 999); const SECONDARY_BUTTONS_DURATION_MULTIPLIER = 1.8; +final CALENDAR_DATE_IN_DURATION_MULTIPLIER = 1.1; const PHOTO_SHOW_AFTER_CREATION_DURATION = Duration(milliseconds: 500); final UnmodifiableSetView DEFAULT_ZOOM_LEVELS = UnmodifiableSetView({0.6, 1, 2, 5, 10, 20, 50, 100}); +const CALENDAR_DATE_IN_MAX_DELAY = Duration(milliseconds: 500); diff --git a/lib/main.dart b/lib/main.dart index 0097a72..96fe7e4 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,6 +2,7 @@ import 'package:camera/camera.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; import 'package:share_location/constants/apis.dart'; import 'package:share_location/screens/calendar_screen.dart'; import 'package:share_location/screens/grant_permission_screen.dart'; @@ -9,10 +10,12 @@ import 'package:share_location/screens/login_screen.dart'; import 'package:share_location/screens/main_screen.dart'; import 'package:share_location/screens/timeline_screen.dart'; import 'package:share_location/screens/welcome_screen.dart'; +import 'package:share_location/utils/auth_required.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import 'managers/global_values_manager.dart'; import 'managers/startup_page_manager.dart'; +import 'models/memories.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -35,7 +38,7 @@ void main() async { runApp(MyApp(initialPage: initialPage)); } -class MyApp extends StatelessWidget { +class MyApp extends StatefulWidget { final String initialPage; const MyApp({ @@ -43,35 +46,65 @@ class MyApp extends StatelessWidget { required this.initialPage, }) : super(key: key); + @override + State createState() => _MyAppState(); +} + +class _MyAppState extends AuthRequiredState { + final memories = Memories(); + + @override + void initState() { + super.initState(); + + memories.addListener(() { + setState(() { + + }); + }, ['isInitializing']); + } + + @override + void onAuthenticated(Session session) { + memories.initialize(); + } + @override Widget build(BuildContext context) { - return MaterialApp( - title: 'Flutter Demo', - theme: ThemeData.dark().copyWith( - textTheme: ThemeData.dark().textTheme.copyWith( - headline1: const TextStyle( - fontSize: 32, - fontWeight: FontWeight.w500, + if (memories.isInitializing) { + return SizedBox(); + } + + return ChangeNotifierProvider.value( + value: memories, + child: MaterialApp( + title: 'Flutter Demo', + theme: ThemeData.dark().copyWith( + textTheme: ThemeData.dark().textTheme.copyWith( + headline1: const TextStyle( + fontSize: 32, + fontWeight: FontWeight.w500, + ), ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + helperMaxLines: 10, + errorMaxLines: 10, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), ), - inputDecorationTheme: InputDecorationTheme( - filled: true, - helperMaxLines: 10, - errorMaxLines: 10, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), ), ), + routes: { + WelcomeScreen.ID: (context) => const WelcomeScreen(), + MainScreen.ID: (context) => const MainScreen(), + LoginScreen.ID: (context) => const LoginScreen(), + TimelineScreen.ID: (context) => const TimelineScreen(), + GrantPermissionScreen.ID: (context) => const GrantPermissionScreen(), + CalendarScreen.ID: (context) => const CalendarScreen(), + }, + initialRoute: widget.initialPage, ), - routes: { - WelcomeScreen.ID: (context) => const WelcomeScreen(), - MainScreen.ID: (context) => const MainScreen(), - LoginScreen.ID: (context) => const LoginScreen(), - TimelineScreen.ID: (context) => const TimelineScreen(), - GrantPermissionScreen.ID: (context) => const GrantPermissionScreen(), - CalendarScreen.ID: (context) => const CalendarScreen(), - }, - initialRoute: initialPage, ); } } diff --git a/lib/managers/calendar_manager.dart b/lib/managers/calendar_manager.dart new file mode 100644 index 0000000..d14f829 --- /dev/null +++ b/lib/managers/calendar_manager.dart @@ -0,0 +1,75 @@ +import 'package:share_location/foreign_types/memory.dart'; +import 'package:share_location/helpers/iterate_months.dart'; + +class CalendarManager { + final Map> _values; + + CalendarManager({ + required final List memories, + }) : _values = mapFromMemoriesList(memories); + + static DateTime createDateKey(final DateTime date) => + DateTime(date.year, date.month, date.day); + + static Map> mapFromMemoriesList( + final List memories) { + final map = >{}; + + for (final memory in memories) { + final key = createDateKey(memory.creationDate); + + if (map.containsKey(key)) { + map[key]!.add(memory.id); + } else { + map[key] = { + memory.id, + }; + } + } + + return map; + } + + static Map> fillEmptyMonths( + Map> monthMapping) { + final earliestDate = + monthMapping.keys.reduce((a, b) => a.isBefore(b) ? a : b); + final latestDate = monthMapping.keys.reduce((a, b) => a.isAfter(b) ? a : b); + + final filledMonthMapping = >{}; + + for (final date in iterateMonths(earliestDate, latestDate)) { + filledMonthMapping[date] = monthMapping[date] ?? {}; + } + + return filledMonthMapping; + } + + Map> getMonthDayAmountMapping() { + final map = >{}; + + for (final entry in _values.entries) { + final date = entry.key; + final monthDate = DateTime(date.year, date.month, 1); + final memoryIDs = entry.value; + + if (map.containsKey(monthDate)) { + map[monthDate]![date] = memoryIDs.length; + } else { + map[monthDate] = { + date: memoryIDs.length, + }; + } + } + + return map; + } + + Map> getMappingForList() { + final monthMapping = fillEmptyMonths(getMonthDayAmountMapping()); + + return Map.fromEntries( + monthMapping.entries.toList()..sort((a, b) => b.key.compareTo(a.key)), + ); + } +} diff --git a/lib/models/memories.dart b/lib/models/memories.dart new file mode 100644 index 0000000..e90473f --- /dev/null +++ b/lib/models/memories.dart @@ -0,0 +1,114 @@ +import 'package:property_change_notifier/property_change_notifier.dart'; +import 'package:share_location/foreign_types/memory.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +final supabase = Supabase.instance.client; + +class Memories extends PropertyChangeNotifier { + final List _memories = []; + + Memories(); + + RealtimeSubscription? _serverSubscription; + bool _isInitializing = true; + + List get memories => _memories; + bool get isInitializing => _isInitializing; + + @override + void dispose() { + _serverSubscription?.unsubscribe(); + + super.dispose(); + } + + void addMemory(final Memory memory) { + _memories.add(memory); + notifyListeners('memories'); + } + + void addAllMemories(final List memories) { + _memories.addAll(memories); + notifyListeners('memories'); + } + + void removeMemory(final Memory memory) { + _memories.remove(memory); + notifyListeners('memories'); + } + + void removeMemoryByID(final String id) { + _memories.removeWhere((memory) => memory.id == id); + notifyListeners('memories'); + } + + void setIsInitializing(final bool value) { + _isInitializing = value; + notifyListeners('isInitializing'); + } + + void sortMemories() { + _memories.sort((a, b) => b.creationDate.compareTo(a.creationDate)); + notifyListeners('memories'); + } + + Future initialize() async { + setIsInitializing(true); + + await _loadInitialData(); + + setIsInitializing(false); + notifyListeners(); + + // Watch new updates + _serverSubscription = supabase + .from('memories') + .on(SupabaseEventTypes.all, _onServerUpdate) + .subscribe(); + } + + Future _onServerUpdate( + final SupabaseRealtimePayload response, + ) async { + if (response == null) { + return; + } + + switch (response.eventType) { + case 'INSERT': + final memory = Memory.parse(response.newRecord!); + + addMemory(memory); + + break; + case 'DELETE': + final id = response.oldRecord!['id']; + + removeMemoryByID(id); + break; + // Used for easier debugging + case 'UPDATE': + final memory = Memory.parse(response.newRecord!); + final id = response.oldRecord!['id']; + + removeMemoryByID(id); + addMemory(memory); + break; + } + + sortMemories(); + } + + Future _loadInitialData() async { + final response = await supabase + .from('memories') + .select() + .order('created_at', ascending: false) + .execute(); + final newMemories = List.from( + List>.from(response.data).map(Memory.parse), + ); + + addAllMemories(newMemories); + } +} diff --git a/lib/models/timeline.dart b/lib/models/timeline.dart index bafdcd1..fdf7a72 100644 --- a/lib/models/timeline.dart +++ b/lib/models/timeline.dart @@ -1,19 +1,17 @@ import 'dart:math'; -import 'package:intl/intl.dart'; import 'package:property_change_notifier/property_change_notifier.dart'; import 'package:share_location/foreign_types/memory.dart'; -import 'package:share_location/models/memory_pack.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; final supabase = Supabase.instance.client; class TimelineModel extends PropertyChangeNotifier { - final Map _timeline; + final Map> _timeline; TimelineModel({ - Map? timeline, - }) : _timeline = timeline ?? {}; + required final List memories, + }) : _timeline = mapFromMemoriesList(memories); RealtimeSubscription? _serverSubscription; @@ -22,38 +20,35 @@ class TimelineModel extends PropertyChangeNotifier { bool _paused = false; bool _isInitializing = true; - Map get values => _timeline; + Map> get values => _timeline; int get length => _timeline.length; int get currentIndex => _currentIndex; int get memoryIndex => _memoryIndex; bool get paused => _paused; bool get isInitializing => _isInitializing; - DateTime dateAtIndex(final int index) => - DateTime.parse(_timeline.keys.elementAt(index)); + DateTime dateAtIndex(final int index) => _timeline.keys.elementAt(index); - MemoryPack atIndex(final int index) => _timeline.values.elementAt(index); + List atIndex(final int index) => _timeline.values.elementAt(index); - MemoryPack get _currentMemoryPack => atIndex(currentIndex); - bool get _isAtLastMemory => - _memoryIndex == _currentMemoryPack.memories.length - 1; - Memory get currentMemory => - _currentMemoryPack.memories.elementAt(_memoryIndex); + List get _currentMemoryPack => atIndex(currentIndex); + bool get _isAtLastMemory => _memoryIndex == _currentMemoryPack.length - 1; + Memory get currentMemory => _currentMemoryPack.elementAt(_memoryIndex); void _removeEmptyDates() { - _timeline.removeWhere((key, value) => value.memories.isEmpty); + _timeline.removeWhere((key, memories) => memories.isEmpty); } - static formatCreationDateKey(final DateTime date) => - DateFormat('yyyy-MM-dd').format(date); + static DateTime createDateKey(final DateTime date) => + DateTime(date.year, date.month, date.day); - static Map mapFromMemoriesList( + static Map> mapFromMemoriesList( final List memories, ) { - final map = >{}; + final map = >{}; for (final memory in memories) { - final key = formatCreationDateKey(memory.creationDate); + final key = createDateKey(memory.creationDate); if (map.containsKey(key)) { map[key]!.add(memory); @@ -62,14 +57,7 @@ class TimelineModel extends PropertyChangeNotifier { } } - return Map.fromEntries( - map.entries.map( - (entry) => MapEntry( - entry.key, - MemoryPack(entry.value), - ), - ), - ); + return map; } @override @@ -85,7 +73,7 @@ class TimelineModel extends PropertyChangeNotifier { void setMemoryIndex(final int index) { _memoryIndex = min( - _timeline.values.elementAt(_currentIndex).memories.length - 1, + _timeline.values.elementAt(_currentIndex).length - 1, max(0, index), ); notifyListeners('memoryIndex'); @@ -118,7 +106,7 @@ class TimelineModel extends PropertyChangeNotifier { } setCurrentIndex(currentIndex - 1); - setMemoryIndex(_currentMemoryPack.memories.length - 1); + setMemoryIndex(_currentMemoryPack.length - 1); } void nextMemory() { @@ -137,100 +125,13 @@ class TimelineModel extends PropertyChangeNotifier { } } - Future initialize() async { + void refresh(final List memories) { setIsInitializing(true); - await _listenToServer(); + _timeline.clear(); + _timeline.addAll(mapFromMemoriesList(memories)); + _removeEmptyDates(); setIsInitializing(false); } - - void _insertMemory(final Memory memory) { - final key = formatCreationDateKey(memory.creationDate); - - if (!_timeline.containsKey(key)) { - _timeline[key] = MemoryPack([memory]); - return; - } - - final memoryPack = _timeline[key]!; - - memoryPack.addMemory(memory); - } - - void _updateMemory(final String id, final Memory memory) { - final key = formatCreationDateKey(memory.creationDate); - - if (!_timeline.containsKey(key)) { - _timeline[key] = MemoryPack([memory]); - return; - } - - final memoryPack = _timeline[key]!; - - memoryPack.updateWithNewMemory(id, memory); - } - - void _deleteMemory(final String id) { - // Search for correct `Memory` and remove it - for (final memories in _timeline.values) { - memories.memories.removeWhere((memory) => memory.id == id); - } - - _removeEmptyDates(); - } - - Future _fetchInitialData() async { - final response = await supabase - .from('memories') - .select() - .order('created_at', ascending: false) - .execute(); - final memories = List.from( - List>.from(response.data).map(Memory.parse), - ); - - values - ..clear() - ..addAll(mapFromMemoriesList(memories)); - } - - Future _onServerUpdate( - final SupabaseRealtimePayload response, - ) async { - if (response == null) { - return; - } - - switch (response.eventType) { - case 'INSERT': - final memory = Memory.parse(response.newRecord!); - _insertMemory(memory); - break; - case 'UPDATE': - final memoryID = response.oldRecord!['id']; - final memory = Memory.parse(response.newRecord!); - - _updateMemory(memoryID, memory); - break; - case 'DELETE': - final id = response.oldRecord!['id']; - - _deleteMemory(id); - break; - } - - notifyListeners(); - } - - Future _listenToServer() async { - await _fetchInitialData(); - notifyListeners(); - - // Watch new updates - _serverSubscription = supabase - .from('memories') - .on(SupabaseEventTypes.all, _onServerUpdate) - .subscribe(); - } } diff --git a/lib/screens/calendar_screen.dart b/lib/screens/calendar_screen.dart index 3c65da5..9b2a4c8 100644 --- a/lib/screens/calendar_screen.dart +++ b/lib/screens/calendar_screen.dart @@ -1,87 +1,59 @@ import 'package:flutter/material.dart'; import 'package:flutter_sticky_header/flutter_sticky_header.dart'; +import 'package:provider/provider.dart'; import 'package:share_location/constants/spacing.dart'; -import 'package:share_location/helpers/iterate_months.dart'; -import 'package:share_location/models/calendar.dart'; +import 'package:share_location/managers/calendar_manager.dart'; +import 'package:share_location/models/memories.dart'; import 'package:share_location/widgets/calendar_month.dart'; import 'package:share_location/widgets/days_of_week_strip.dart'; -class CalendarScreen extends StatefulWidget { +class CalendarScreen extends StatelessWidget { static const ID = 'calendar'; - const CalendarScreen({Key? key}) : super(key: key); - - @override - State createState() => _CalendarScreenState(); -} - -class _CalendarScreenState extends State { - final calendar = CalendarModel(); - - @override - void initState() { - super.initState(); - - calendar.initialize(); - - calendar.addListener(() { - setState(() {}); - }); - } - - static Map> fillEmptyMonths( - Map> monthMapping) { - final earliestDate = - monthMapping.keys.reduce((a, b) => a.isBefore(b) ? a : b); - final latestDate = monthMapping.keys.reduce((a, b) => a.isAfter(b) ? a : b); - - final filledMonthMapping = >{}; - - for (final date in iterateMonths(earliestDate, latestDate)) { - filledMonthMapping[date] = monthMapping[date] ?? {}; - } - - return filledMonthMapping; - } + const CalendarScreen({ + Key? key, + }) : super(key: key); @override Widget build(BuildContext context) { - if (calendar.isInitializing) { - return const Scaffold( - body: Center( - child: CircularProgressIndicator(), - ), - ); - } - + final memoriesManager = context.read(); final theme = Theme.of(context); - final monthMapping = fillEmptyMonths(calendar.getMonthDayAmountMapping()); - final sortedMonthMapping = Map.fromEntries( - monthMapping.entries.toList()..sort((a, b) => b.key.compareTo(a.key)), - ); - return Scaffold( - body: CustomScrollView( - reverse: true, - slivers: [ - SliverStickyHeader( - header: Container( - color: theme.canvasColor, - padding: const EdgeInsets.symmetric(vertical: SMALL_SPACE), - child: const DaysOfWeekStrip(), - ), - sliver: SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) => CalendarMonth( - year: sortedMonthMapping.keys.elementAt(index).year, - month: sortedMonthMapping.keys.elementAt(index).month, - dayAmountMap: sortedMonthMapping.values.elementAt(index), + final calendarManager = CalendarManager(memories: memoriesManager.memories); + final monthMapping = calendarManager.getMappingForList(); + + return Consumer( + builder: (context, memories, _) => Scaffold( + body: Padding( + padding: const EdgeInsets.symmetric(vertical: MEDIUM_SPACE), + child: CustomScrollView( + reverse: true, + slivers: [ + SliverStickyHeader( + header: Container( + color: theme.canvasColor, + padding: const EdgeInsets.symmetric(vertical: SMALL_SPACE), + child: const DaysOfWeekStrip(), + ), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final date = monthMapping.keys.elementAt(index); + final dayMapping = monthMapping.values.elementAt(index); + + return CalendarMonth( + year: date.year, + month: date.month, + dayAmountMap: dayMapping, + ); + }, + childCount: monthMapping.length, + ), ), - childCount: sortedMonthMapping.length, ), - ), + ], ), - ], + ), ), ); } diff --git a/lib/screens/timeline_screen.dart b/lib/screens/timeline_screen.dart index a0d2d34..30d4d49 100644 --- a/lib/screens/timeline_screen.dart +++ b/lib/screens/timeline_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:share_location/extensions/date.dart'; +import 'package:share_location/models/memories.dart'; import 'package:share_location/models/timeline.dart'; import 'package:share_location/utils/loadable.dart'; import 'package:share_location/widgets/timeline_page.dart'; @@ -26,7 +27,7 @@ class TimelineScreen extends StatefulWidget { class _TimelineScreenState extends State with Loadable { final pageController = PageController(); - final timeline = TimelineModel(); + late final TimelineModel timeline; bool _ignorePageChanges = false; Future _goToPage(final int page) async { @@ -45,7 +46,13 @@ class _TimelineScreenState extends State with Loadable { initState() { super.initState(); - timeline.initialize(); + final memoriesModel = context.read(); + + timeline = TimelineModel(memories: memoriesModel.memories); + + memoriesModel.addListener(() { + timeline.refresh(memoriesModel.memories); + }, ['memories']); // Update page view timeline.addListener(() async { @@ -86,22 +93,16 @@ class _TimelineScreenState extends State with Loadable { return timeline.values.keys .toList() - .indexWhere((date) => DateTime.parse(date).isSameDay(widget.date!)); + .indexWhere((date) => date.isSameDay(widget.date!)); } @override Widget build(BuildContext context) { - if (timeline.isInitializing) { - return const Center( - child: CircularProgressIndicator(), - ); - } - return WillPopScope( onWillPop: () async { - await Navigator.pushNamed(context, CalendarScreen.ID); + await Navigator.pushReplacementNamed(context, CalendarScreen.ID); - return true; + return false; }, child: Scaffold( body: ChangeNotifierProvider.value( @@ -124,7 +125,7 @@ class _TimelineScreenState extends State with Loadable { }, itemBuilder: (_, index) => TimelinePage( date: timeline.dateAtIndex(index), - memoryPack: timeline.atIndex(index), + memories: timeline.atIndex(index), ), ), ), diff --git a/lib/widgets/calendar_month.dart b/lib/widgets/calendar_month.dart index db12963..8232047 100644 --- a/lib/widgets/calendar_month.dart +++ b/lib/widgets/calendar_month.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_calendar_widget/flutter_calendar_widget.dart'; import 'package:intl/intl.dart'; import 'package:share_location/constants/spacing.dart'; +import 'package:share_location/constants/values.dart'; import 'package:share_location/screens/timeline_screen.dart'; import 'package:share_location/widgets/delay_render.dart'; import 'package:share_location/widgets/fade_and_move_in_animation.dart'; @@ -37,11 +38,18 @@ class MonthCalendarBuilder extends CalendarBuilder { return amount / highestAmountOfEvents; }(); - final duration = Duration(milliseconds: Random().nextInt(800)); + final delay = Duration( + microseconds: + Random().nextInt(CALENDAR_DATE_IN_MAX_DELAY.inMicroseconds)); return DelayRender( - delay: duration, + delay: delay, child: FadeAndMoveInAnimation( + opacityDuration: + DEFAULT_OPACITY_DURATION * CALENDAR_DATE_IN_DURATION_MULTIPLIER, + translationDuration: + DEFAULT_TRANSLATION_DURATION * CALENDAR_DATE_IN_DURATION_MULTIPLIER, + translationOffset: const Offset(0.0, -MEDIUM_SPACE), child: Opacity( opacity: () { if (type.isOutSide) { diff --git a/lib/widgets/timeline_page.dart b/lib/widgets/timeline_page.dart index fb425c3..1be199c 100644 --- a/lib/widgets/timeline_page.dart +++ b/lib/widgets/timeline_page.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:share_location/models/memory_pack.dart'; +import 'package:share_location/foreign_types/memory.dart'; import 'package:share_location/models/timeline.dart'; import 'package:share_location/models/timeline_overlay.dart'; import 'package:share_location/widgets/memory_sheet.dart'; @@ -11,12 +11,12 @@ import 'package:share_location/widgets/timeline_overlay.dart'; class TimelinePage extends StatefulWidget { final DateTime date; - final MemoryPack memoryPack; + final List memories; const TimelinePage({ Key? key, required this.date, - required this.memoryPack, + required this.memories, }) : super(key: key); @override @@ -165,14 +165,14 @@ class _TimelinePageState extends State { physics: const NeverScrollableScrollPhysics(), scrollDirection: Axis.horizontal, itemBuilder: (_, index) => MemorySlide( - key: Key(widget.memoryPack.memories[index].filename), - memory: widget.memoryPack.memories[index], + key: Key(widget.memories[index].filename), + memory: widget.memories[index], ), - itemCount: widget.memoryPack.memories.length, + itemCount: widget.memories.length, ), TimelineOverlay( date: widget.date, - memoriesAmount: widget.memoryPack.memories.length, + memoriesAmount: widget.memories.length, memoryIndex: timeline.memoryIndex + 1, ), ],